Added support for custom avatars

This commit is contained in:
Marcin Kurczewski 2014-05-20 19:46:05 +02:00
parent 3051f37587
commit fee19c61bc
27 changed files with 395 additions and 9 deletions

View file

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

2
public_html/avatars/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -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();
}
});
});

View file

@ -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';

View file

@ -0,0 +1,64 @@
<?php
class EditUserAvatarJob extends AbstractJob
{
protected $userRetriever;
public function __construct()
{
$this->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;
}
}

View file

@ -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)

View file

@ -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);

View file

@ -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';

View file

@ -0,0 +1,44 @@
<?php
class UserAvatarStyle extends AbstractEnum implements IEnum, IValidatable
{
const Gravatar = 1;
const Custom = 2;
const None = 3;
private $type;
public function __construct($type)
{
$this->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);
}
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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)

View file

@ -0,0 +1 @@
ALTER TABLE user ADD COLUMN avatar_style INTEGER DEFAULT 1;

View file

@ -0,0 +1 @@
ALTER TABLE user ADD COLUMN avatar_style INTEGER DEFAULT 1;

View file

@ -19,7 +19,7 @@ $noAutocomplete = isset($this->context->noAutocomplete) ? true : false;
<?php if ($noAutocomplete): ?>
autocomplete="off"
<?php endif ?>
<?php if ($inputClass != ''): ?>
<?php if ($inputClass): ?>
class="<?= $inputClass ?>"
<?php endif ?>
type="<?= $type ?>"

View file

@ -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 : '';
?>
<div class="form-row">
@ -17,6 +18,9 @@ $keys = array_keys($optionNames);
<?php foreach ($keys as $key): ?>
<input type="hidden"
<?php if ($inputClass): ?>
class="<?= $inputclass ?>"
<?php endif ?>
name="<?= $optionNames[$key] ?>"
value="<?= $optionValuesDisabled[$key] ?>"/>

View file

@ -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 : '';
?>
<div class="form-row">
@ -18,6 +19,9 @@ $keys = array_keys($optionValues);
<label>
<input type="radio"
name="<?= $name ?>"
<?php if ($inputClass): ?>
class="<?= $inputClass ?>"
<?php endif ?>
<?php if (count($keys) == 1): ?>
id="<?= $id ?>"
<?php endif ?>

View file

@ -6,6 +6,7 @@ $optionValues = $this->context->optionValues;
$optionLabels = $this->context->optionLabels;
$activeOptionValue = isset($this->context->activeOptionValue) ? $this->context->activeOptionValue : null;
$keys = array_keys($optionValues);
$inputClass = isset($this->context->inputClass) ? $this->context->inputClass : '';
?>
<div class="form-row">
@ -13,7 +14,13 @@ $keys = array_keys($optionValues);
<?= $label ?>
</label>
<div class="input-wrapper">
<select name="<?= $name ?>" id="<?= $id ?>">
<select
<?php if ($inputClass): ?>
class="<?= $inputclass ?>"
<?php endif ?>
name="<?= $name ?>"
id="<?= $id ?>">
<?php foreach ($keys as $key): ?>
<?php if ($activeOptionValue == $optionValues[$key]): ?>
<option value="<?= $optionValues[$key] ?>" selected="selected">
@ -23,6 +30,7 @@ $keys = array_keys($optionValues);
<?= $optionLabels[$key] ?>
</option>
<?php endforeach ?>
</select>
</div>
</div>

View file

@ -1,7 +1,12 @@
<?php
$this->assets->addScript('user-edit.js');
?>
<form
action="<?= \Chibi\Router::linkTo(
['UserController', 'editAction'],
['identifier' => $this->context->transport->user->getName()]) ?>"
enctype="multipart/form-data"
method="post"
class="edit"
autocomplete="off">
@ -17,6 +22,26 @@
echo '<hr/>';
}
if (Access::check(new Privilege(
Privilege::EditUserAvatar,
Access::getIdentity($this->context->transport->user))))
{
$styles = UserAvatarStyle::getAll();
$context = new StdClass;
$context->name = 'avatar-style';
$context->label = 'User picture';
$context->optionValues = array_map(function($s) { return $s->toInteger(); }, $styles);
$context->optionLabels = array_map(function($s) { return ucfirst($s->toDisplayString()); }, $styles);
$context->activeOptionValue = $this->context->transport->user->getAvatarStyle()->toInteger();
$context->inputClass = 'avatar-style';
$this->renderExternal('input-radioboxes', $context);
$context = new StdClass;
$context->name = 'avatar-content';
$context->inputClass = 'avatar-content';
$this->renderExternal('input-file', $context);
}
if (Access::check(new Privilege(
Privilege::EditUserName,
Access::getIdentity($this->context->transport->user))))

View file

@ -68,6 +68,7 @@ final class Core
TransferHelper::createDirectory($config->main->filesPath);
TransferHelper::createDirectory($config->main->thumbnailsPath);
TransferHelper::createDirectory($config->main->avatarsPath);
//extension sanity checks
$requiredExtensions = ['pdo', 'pdo_' . $config->main->dbDriver, 'openssl', 'fileinfo'];

View file

@ -113,6 +113,7 @@ class SzurubooruTestRunner implements ITestRunner
[
realpath(Core::getConfig()->main->filesPath),
realpath(Core::getConfig()->main->thumbnailsPath),
realpath(Core::getConfig()->main->avatarsPath),
realpath(dirname(Core::getConfig()->main->logsPath)),
];

View file

@ -147,6 +147,15 @@ class ApiArgumentTest extends AbstractFullApiTest
JobArgs::ARG_NEW_USER_NAME));
}
public function testEditUserAvatarJob()
{
$this->testArguments(new EditUserAvatarJob(),
JobArgs::Conjunction(
$this->getUserSelector(),
JobArgs::ARG_NEW_AVATAR_STYLE,
JobArgs::Optional(JobArgs::ARG_NEW_AVATAR_CONTENT)));
}
public function testEditUserPasswordJob()
{
$this->testArguments(new EditUserPasswordJob(),

View file

@ -24,6 +24,7 @@ class ApiAuthTest extends AbstractFullApiTest
$this->testAuth(new EditUserEmailJob(), false);
$this->testAuth(new EditUserNameJob(), false);
$this->testAuth(new EditUserPasswordJob(), false);
$this->testAuth(new EditUserAvatarJob(), false);
$this->testAuth(new EditUserSettingsJob(), false);
$this->testAuth(new FeaturePostJob(), true);
$this->testAuth(new FlagPostJob(), false);

View file

@ -25,6 +25,7 @@ class ApiEmailRequirementsTest extends AbstractFullApiTest
$this->testRegularEmailRequirement(new EditUserEmailJob());
$this->testRegularEmailRequirement(new EditUserNameJob());
$this->testRegularEmailRequirement(new EditUserPasswordJob());
$this->testRegularEmailRequirement(new EditUserAvatarJob());
$this->testRegularEmailRequirement(new EditUserSettingsJob());
$this->testRegularEmailRequirement(new FeaturePostJob());
$this->testRegularEmailRequirement(new FlagPostJob());

View file

@ -134,6 +134,7 @@ class ApiPrivilegeTest extends AbstractFullApiTest
$this->testDynamicUserPrivilege(new EditUserEmailJob(), Privilege::EditUserEmail);
$this->testDynamicUserPrivilege(new EditUserNameJob(), Privilege::EditUserName);
$this->testDynamicUserPrivilege(new EditUserPasswordJob(), Privilege::EditUserPassword);
$this->testDynamicUserPrivilege(new EditUserAvatarJob(), Privilege::EditUserAvatar);
$this->testDynamicUserPrivilege(new EditUserSettingsJob(), Privilege::EditUserSettings);
$ctx = function($job)
@ -145,6 +146,7 @@ class ApiPrivilegeTest extends AbstractFullApiTest
$this->testDynamicUserPrivilege($ctx(new EditUserEmailJob()), Privilege::RegisterAccount);
$this->testDynamicUserPrivilege($ctx(new EditUserNameJob()), Privilege::RegisterAccount);
$this->testDynamicUserPrivilege($ctx(new EditUserPasswordJob()), Privilege::RegisterAccount);
$this->testDynamicUserPrivilege($ctx(new EditUserAvatarJob()), Privilege::RegisterAccount);
$this->testDynamicUserPrivilege($ctx(new EditUserSettingsJob()), Privilege::EditUserSettings);
$this->testDynamicUserPrivilege(new FlagUserJob(), Privilege::FlagUser);

View file

@ -0,0 +1,88 @@
<?php
class EditUserAvatarJobTest extends AbstractTest
{
public function testGravatar()
{
$this->grantAccess('editUserAvatar');
$user = $this->userMocker->mockSingle();
$this->assert->areEqual(UserAvatarStyle::Gravatar, $user->getAvatarStyle()->toInteger());
$user = $this->assert->doesNotThrow(function() use ($user)
{
return Api::run(
new EditUserAvatarJob(),
[
JobArgs::ARG_USER_NAME => $user->getName(),
JobArgs::ARG_NEW_AVATAR_STYLE => UserAvatarStyle::Gravatar,
]);
});
$this->assert->areEqual(UserAvatarStyle::Gravatar, $user->getAvatarStyle()->toInteger());
$hash = md5($user->getPasswordSalt() . $user->getName());
$this->assert->isTrue(strpos($user->getAvatarUrl(), $hash) !== false);
$mail = 'postmaster@mordor.cx';
$user->setConfirmedEmail($mail);
UserModel::save($user);
$hash = md5($mail);
$this->assert->isTrue(strpos($user->getAvatarUrl(), $hash) !== false);
}
public function testEmpty()
{
$this->grantAccess('editUserAvatar');
$user = $this->userMocker->mockSingle();
$this->assert->areEqual(UserAvatarStyle::Gravatar, $user->getAvatarStyle()->toInteger());
$user = $this->assert->doesNotThrow(function() use ($user)
{
return Api::run(
new EditUserAvatarJob(),
[
JobArgs::ARG_USER_NAME => $user->getName(),
JobArgs::ARG_NEW_AVATAR_STYLE => UserAvatarStyle::None,
]);
});
$this->assert->areEqual(UserAvatarStyle::None, $user->getAvatarStyle()->toInteger());
$hash = md5($user->getPasswordSalt() . $user->getName());
$this->assert->isTrue(strpos($user->getAvatarUrl(), $hash) === false);
$mail = 'postmaster@mordor.cx';
$user->setConfirmedEmail($mail);
UserModel::save($user);
$hash = md5($mail);
$this->assert->isTrue(strpos($user->getAvatarUrl(), $hash) === false);
}
public function testCustom()
{
$this->grantAccess('editUserAvatar');
$user = $this->userMocker->mockSingle();
$this->assert->areEqual(UserAvatarStyle::Gravatar, $user->getAvatarStyle()->toInteger());
$user = $this->assert->doesNotThrow(function() use ($user)
{
return Api::run(
new EditUserAvatarJob(),
[
JobArgs::ARG_USER_NAME => $user->getName(),
JobArgs::ARG_NEW_AVATAR_STYLE => UserAvatarStyle::Custom,
JobArgs::ARG_NEW_AVATAR_CONTENT => new ApiFileInput($this->testSupport->getPath('image.jpg'), 'image.jpg')
]);
});
$this->assert->areEqual(UserAvatarStyle::Custom, $user->getAvatarStyle()->toInteger());
$hash = md5($user->getPasswordSalt() . $user->getName());
$this->assert->isTrue(strpos($user->getAvatarUrl(), $hash) === false);
$this->assert->isTrue(strpos($user->getAvatarUrl(32), '32') !== false);
}
}

View file

@ -1,6 +1,7 @@
[main]
filesPath = "./tests/files/"
thumbnailsPath = "./tests/thumbs/"
avatarsPath = "./tests/avatars/"
logsPath = "./tests/logs/{yyyy}-{mm}.log"
mediaPath = "./public_html/media/"
title = "szurubooru/tests"