Added account settings management and avatars

This commit is contained in:
Marcin Kurczewski 2014-09-07 00:33:46 +02:00
parent bfee96c59e
commit ee2ca7fbaf
40 changed files with 1178 additions and 175 deletions

3
data/avatars/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*
!blank.png
!.gitignore

BIN
data/avatars/blank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -8,12 +8,25 @@ secret = change
minPasswordLength = 5
[security.privileges]
register = anonymous
listUsers = regularUser, powerUser, moderator, administrator
deleteOwnAccount = regularUser, powerUser, moderator, administrator
deleteAllAccounts = administrator
register = anonymous
listUsers = regularUser, powerUser, moderator, administrator
deleteOwnAccount = regularUser, powerUser, moderator, administrator
deleteAllAccounts = administrator
changeOwnName = regularUser, powerUser, moderator, administrator
changeOwnAvatarStyle = regularUser, powerUser, moderator, administrator
changeOwnEmailAddress = regularUser, powerUser, moderator, administrator
changeOwnName = regularUser, powerUser, moderator, administrator
changeOwnPassword = regularUser, powerUser, moderator, administrator
changeAllAvatarStyles = moderator, administrator
changeAllEmailAddresses = moderator, administrator
changeAllNames = moderator, administrator
changeAllPasswords = moderator, administrator
changeAccessRank = administrator
[users]
minUserNameLength = 1
maxUserNameLength = 32
usersPerPage = 20
[misc]
thumbnailCropStyle = outside

View file

@ -44,3 +44,15 @@ input[type=button] {
font-family: 'Droid Sans', sans-serif;
font-size: 17px;
}
.file-handler {
border: 3px dashed #eee;
padding: 0.3em 0.5em;
line-height: 140% !important;
text-align: center;
cursor: pointer;
}
.file-handler.active {
border-color: #6a2;
background-color: #eeffcc;
}

View file

@ -33,6 +33,7 @@
<script type="text/javascript" src="/js/Api.js"></script>
<script type="text/javascript" src="/js/Auth.js"></script>
<script type="text/javascript" src="/js/Util.js"></script>
<script type="text/javascript" src="/js/Controls/FileDropper.js"></script>
<script type="text/javascript" src="/js/Presenters/TopNavigationPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/PagedCollectionPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/LoginPresenter.js"></script>
@ -40,6 +41,9 @@
<script type="text/javascript" src="/js/Presenters/MessagePresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/RegistrationPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/UserListPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/UserBrowsingSettingsPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/UserAccountSettingsPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/UserAccountRemovalPresenter.js"></script>
<script type="text/javascript" src="/js/Presenters/UserPresenter.js"></script>
<script type="text/javascript" src="/js/Router.js"></script>
<script type="text/javascript" src="/js/Bootstrap.js"></script>

View file

@ -5,6 +5,16 @@ App.Auth = function(jQuery, util, api, appState, promise) {
var privileges = {
register: 'register',
listUsers: 'listUsers',
viewAllEmailAddresses: 'viewAllEmailAddresses',
changeAccessRank: 'changeAccessRank',
changeOwnAvatarStyle: 'changeOwnAvatarStyle',
changeOwnEmailAddress: 'changeOwnEmailAddress',
changeOwnName: 'changeOwnName',
changeOwnPassword: 'changeOwnPassword',
changeAllAvatarStyles: 'changeAllAvatarStyles',
changeAllEmailAddresses: 'changeAllEmailAddresses',
changeAllNames: 'changeAllNames',
changeAllPasswords: 'changeAllPasswords',
deleteOwnAccount: 'deleteOwnAccount',
deleteAllAccounts: 'deleteAllAccounts',
};

View file

@ -0,0 +1,54 @@
var App = App || {};
App.Controls = App.Controls || {};
App.Controls.FileDropper = function(
$fileInput,
allowMultiple,
onChange,
jQuery) {
var $dropDiv = jQuery('<div class="file-handler"></div>');
$dropDiv.html((allowMultiple ? 'Drop files here!' : 'Drop file here!') + '<br/>Or just click on this box.');
$dropDiv.insertBefore($fileInput);
$fileInput.attr('multiple', allowMultiple);
$fileInput.hide();
$fileInput.change(function(e)
{
addFiles(this.files);
});
$dropDiv.on('dragenter', function(e)
{
$dropDiv.addClass('active');
}).on('dragleave', function(e)
{
$dropDiv.removeClass('active');
}).on('dragover', function(e)
{
e.preventDefault();
}).on('drop', function(e)
{
e.preventDefault();
addFiles(e.originalEvent.dataTransfer.files);
}).on('click', function(e)
{
$fileInput.show().focus().trigger('click').hide();
$dropDiv.addClass('active');
});
function getFiles() {
return files;
}
function addFiles(files) {
$dropDiv.removeClass('active');
if (!allowMultiple && files.length > 1) {
alert('Cannot select multiple files.');
return;
}
onChange(files);
}
}
App.DI.register('fileDropper', App.Controls.FileDropper);

View file

@ -31,20 +31,20 @@ App.Presenters.RegistrationPresenter = function(
e.preventDefault();
messagePresenter.hideMessages($messages);
registrationData = {
userName: $el.find('[name=user]').val(),
password: $el.find('[name=password1]').val(),
passwordConfirmation: $el.find('[name=password2]').val(),
formData = {
userName: $el.find('[name=userName]').val(),
password: $el.find('[name=password]').val(),
passwordConfirmation: $el.find('[name=passwordConfirmation]').val(),
email: $el.find('[name=email]').val(),
};
if (!validateRegistrationData(registrationData))
if (!validateRegistrationFormData(formData))
return;
api.post('/users', registrationData)
api.post('/users', formData)
.then(function(response) {
registrationSuccess(response);
}).catch(function(response) {
}).fail(function(response) {
registrationFailure(response);
});
}
@ -62,18 +62,18 @@ App.Presenters.RegistrationPresenter = function(
messagePresenter.showError($messages, apiResponse.json && apiResponse.json.error || apiResponse);
}
function validateRegistrationData(registrationData) {
if (registrationData.userName.length == 0) {
function validateRegistrationFormData(formData) {
if (formData.userName.length == 0) {
messagePresenter.showError($messages, 'User name cannot be empty.');
return false;
}
if (registrationData.password.length == 0) {
if (formData.password.length == 0) {
messagePresenter.showError($messages, 'Password cannot be empty.');
return false;
}
if (registrationData.password != registrationData.passwordConfirmation) {
if (formData.password != formData.passwordConfirmation) {
messagePresenter.showError($messages, 'Passwords must be the same.');
return false;
}

View file

@ -0,0 +1,78 @@
var App = App || {};
App.Presenters = App.Presenters || {};
App.Presenters.UserAccountRemovalPresenter = function(
jQuery,
util,
promise,
api,
auth,
router,
messagePresenter) {
var target;
var template;
var user;
var privileges = {};
function init(args) {
return promise.make(function(resolve, reject) {
user = args.user;
target = args.target;
privileges.canDeleteAccount =
auth.hasPrivilege(auth.privileges.deleteAllAccounts) ||
(auth.hasPrivilege(auth.privileges.deleteOwnAccount) && auth.isLoggedIn(user.name));
promise.wait(util.promiseTemplate('account-removal')).then(function(html) {
template = _.template(html);
render();
resolve();
});
});
}
function render() {
$el = jQuery(target);
$el.html(template({
user: user,
canDeleteAccount: privileges.canDeleteAccount}));
$el.find('form').submit(accountRemovalFormSubmitted);
}
function getPrivileges() {
return privileges;
}
function accountRemovalFormSubmitted(e) {
e.preventDefault();
$messages = jQuery(target).find('.messages');
messagePresenter.hideMessages($messages);
if (!$el.find('input[name=confirmation]:visible').prop('checked')) {
messagePresenter.showError($messages, 'Must confirm to proceed.');
return;
}
api.delete('/users/' + user.name)
.then(function() {
auth.logout();
var $messageDiv = messagePresenter.showInfo($messages, 'Account deleted. <a href="">Back to main page</a>');
$messageDiv.find('a').click(mainPageLinkClicked);
}).fail(function(response) {
messagePresenter.showError($messages, response.json && response.json.error || response);
});
}
function mainPageLinkClicked(e) {
e.preventDefault();
router.navigateToMainPage();
}
return {
init: init,
render: render,
getPrivileges: getPrivileges
};
};
App.DI.register('userAccountRemovalPresenter', App.Presenters.UserAccountRemovalPresenter);

View file

@ -0,0 +1,154 @@
var App = App || {};
App.Presenters = App.Presenters || {};
App.Presenters.UserAccountSettingsPresenter = function(
jQuery,
util,
promise,
api,
auth,
messagePresenter) {
var $messages;
var target;
var template;
var user;
var privileges;
var avatarContent;
function init(args) {
return promise.make(function(resolve, reject) {
user = args.user;
target = args.target;
privileges = {
canChangeAccessRank:
auth.hasPrivilege(auth.privileges.changeAccessRank),
canChangeAvatarStyle:
auth.hasPrivilege(auth.privileges.changeAllAvatarStyles) ||
(auth.hasPrivilege(auth.privileges.changeOwnAvatarStyle) && auth.isLoggedIn(user.name)),
canChangeName:
auth.hasPrivilege(auth.privileges.changeAllNames) ||
(auth.hasPrivilege(auth.privileges.changeOwnName) && auth.isLoggedIn(user.name)),
canChangeEmailAddress:
auth.hasPrivilege(auth.privileges.changeAllEmailAddresses) ||
(auth.hasPrivilege(auth.privileges.changeOwnEmailAddress) && auth.isLoggedIn(user.name)),
canChangePassword:
auth.hasPrivilege(auth.privileges.changeAllPasswords) ||
(auth.hasPrivilege(auth.privileges.changeOwnPassword) && auth.isLoggedIn(user.name)),
};
promise.wait(util.promiseTemplate('account-settings')).then(function(html) {
template = _.template(html);
render();
resolve();
});
});
}
function render() {
var $el = jQuery(target);
$el.html(template(_.extend({user: user}, privileges)));
$el.find('form').submit(accountSettingsFormSubmitted);
$el.find('form [name=avatar-style]').change(avatarStyleChanged);
avatarStyleChanged();
new App.Controls.FileDropper($el.find('[name=avatar-content]'), false, avatarContentChanged, jQuery);
}
function getPrivileges() {
return privileges;
}
function avatarStyleChanged(e) {
var $el = jQuery(target);
var $target = $el.find('.avatar-content .file-handler');
if ($el.find('[name=avatar-style]:checked').val() == 'manual') {
$target.show();
} else {
$target.hide();
}
}
function avatarContentChanged(files) {
if (files.length == 1) {
var reader = new FileReader();
reader.onloadend = function() {
avatarContent = reader.result;
var $el = jQuery(target);
var $target = $el.find('.avatar-content .file-handler');
$target.html(files[0].name);
}
reader.readAsDataURL(files[0]);
}
}
function accountSettingsFormSubmitted(e) {
e.preventDefault();
var $el = jQuery(target);
var $messages = jQuery(target).find('.messages');
messagePresenter.hideMessages($messages);
var formData = {};
if (privileges.canChangeAvatarStyle) {
formData.avatarStyle = $el.find('[name=avatar-style]:checked').val();
if (avatarContent)
formData.avatarContent = avatarContent;
}
if (privileges.canChangeName) {
formData.userName = $el.find('[name=userName]').val();
}
if (privileges.canChangeEmailAddress) {
formData.email = $el.find('[name=email]').val();
}
if (privileges.canChangePassword) {
formData.password = $el.find('[name=password]').val();
formData.passwordConfirmation = $el.find('[name=passwordConfirmation]').val();
}
if (privileges.canChangeAccessRank) {
formData.accessRank = $el.find('[name=access-rank]').val();
}
if (!validateAccountSettingsFormData($messages, formData)) {
return;
}
if (!formData.password) {
delete formData.password;
delete formData.passwordConfirmation;
}
api.put('/users/' + user.name, formData)
.then(function(response) {
editSuccess($messages, response);
}).fail(function(response) {
editFailure($messages, response);
});
}
function editSuccess($messages, apiResponse) {
//todo: tell user if it turned out that he needs to confirm his e-mail
var message = 'Account settings updated!';
messagePresenter.showInfo($messages, message);
}
function editFailure($messages, apiResponse) {
messagePresenter.showError($messages, apiResponse.json && apiResponse.json.error || apiResponse);
}
function validateAccountSettingsFormData($messages, formData) {
if (formData.password != formData.passwordConfirmation) {
messagePresenter.showError($messages, 'Passwords must be the same.');
return false;
}
return true;
}
return {
init: init,
render: render,
getPrivileges: getPrivileges,
};
}
App.DI.register('userAccountSettingsPresenter', App.Presenters.UserAccountSettingsPresenter);

View file

@ -0,0 +1,46 @@
var App = App || {};
App.Presenters = App.Presenters || {};
App.Presenters.UserBrowsingSettingsPresenter = function(
jQuery,
util,
promise,
auth) {
var target;
var template;
var user;
var privileges = {};
function init(args) {
return promise.make(function(resolve, reject) {
user = args.user;
target = args.target;
privileges.canChangeBrowsingSettings = auth.isLoggedIn(user.name) && user.name == auth.getCurrentUser().name;
promise.wait(util.promiseTemplate('browsing-settings')).then(function(html) {
template = _.template(html);
render();
resolve();
});
});
}
function render() {
$el = jQuery(target);
$el.html(template({user: user}));
}
function getPrivileges() {
return privileges;
}
return {
init: init,
render: render,
getPrivileges: getPrivileges,
};
}
App.DI.register('userBrowsingSettingsPresenter', App.Presenters.UserBrowsingSettingsPresenter);

View file

@ -8,14 +8,14 @@ App.Presenters.UserPresenter = function(
api,
auth,
topNavigationPresenter,
userBrowsingSettingsPresenter,
userAccountSettingsPresenter,
userAccountRemovalPresenter,
messagePresenter) {
var $el = jQuery('#content');
var $messages = $el;
var template;
var accountSettingsTemplate;
var accountRemovalTemplate;
var browsingSettingsTemplate;
var user;
var userName;
@ -25,23 +25,22 @@ App.Presenters.UserPresenter = function(
promise.waitAll(
util.promiseTemplate('user'),
util.promiseTemplate('account-settings'),
util.promiseTemplate('account-removal'),
util.promiseTemplate('browsing-settings'),
api.get('/users/' + userName))
.then(function(
userHtml,
accountSettingsHtml,
accountRemovalHtml,
browsingSettingsHtml,
response) {
$messages = $el.find('.messages');
template = _.template(userHtml);
accountSettingsTemplate = _.template(accountSettingsHtml);
accountRemovalTemplate = _.template(accountRemovalHtml);
browsingSettingsTemplate = _.template(browsingSettingsHtml);
user = response.json;
render();
var extendedContext = _.extend(args, {user: user});
promise.waitAll(
userBrowsingSettingsPresenter.init(_.extend(extendedContext, {target: '#browsing-settings-target'})),
userAccountSettingsPresenter.init(_.extend(extendedContext, {target: '#account-settings-target'})),
userAccountRemovalPresenter.init(_.extend(extendedContext, {target: '#account-removal-target'})))
.then(render);
}).fail(function(response) {
$el.empty();
messagePresenter.showError($messages, response.json && response.json.error || response);
@ -49,37 +48,16 @@ App.Presenters.UserPresenter = function(
}
function render() {
var context = {
$el.html(template({
user: user,
canDeleteAccount: auth.hasPrivilege(auth.privileges.deleteAllAccounts) ||
(auth.isLoggedIn(userName) && auth.hasPrivilege(auth.privileges.deleteOwnAccount)),
};
$el.html(template(context));
$el.find('.browsing-settings').html(browsingSettingsTemplate(context));
$el.find('.account-settings').html(accountSettingsTemplate(context));
$el.find('.account-removal').html(accountRemovalTemplate(context));
$el.find('.account-removal form').submit(accountRemovalFormSubmitted);
$messages = $el.find('.messages');
canChangeBrowsingSettings: userBrowsingSettingsPresenter.getPrivileges().canChangeBrowsingSettings,
canChangeAccountSettings: _.any(userAccountSettingsPresenter.getPrivileges()),
canDeleteAccount: userAccountRemovalPresenter.getPrivileges().canDeleteAccount}));
userBrowsingSettingsPresenter.render();
userAccountSettingsPresenter.render();
userAccountRemovalPresenter.render();
};
function accountRemovalFormSubmitted(e) {
e.preventDefault();
$messages = $el.find('.account-removal .messages');
messagePresenter.hideMessages($messages);
if (!$el.find('.account-removal input[name=confirmation]:visible').prop('checked')) {
messagePresenter.showError($messages, 'Must confirm to proceed.');
return;
}
api.delete('/users/' + user.name)
.then(function() {
auth.logout();
var $messageDiv = messagePresenter.showInfo($messages, 'Account deleted. <a href="">Back to main page</a>');
$messageDiv.find('a').click(mainPageLinkClicked);
}).fail(function(response) {
messagePresenter.showError($messages, response.json && response.json.error || response);
});
}
return {
init: init,
render: render

View file

@ -44,7 +44,7 @@ App.Util = (function(jQuery, promise) {
} else {
lastContentPresenter.reinit.call(presenter, args);
}
};
}
function promiseTemplate(templateName) {
return promiseTemplateFromCache(templateName)
@ -83,7 +83,7 @@ App.Util = (function(jQuery, promise) {
resolve(data);
},
error: function(xhr, textStatus, errorThrown) {
console.log(Error('Error while loading template ' + templateName + ': ' + errorThrown));
console.log(Error('Error while loading template ' + templateName + ': ' + errorThrown));
reject();
},
});

View file

@ -1,4 +1,4 @@
<form class="account-settings">
<form class="account-removal">
<div class="messages"></div>
<div class="form-row">
@ -15,7 +15,7 @@
<div class="form-row">
<label class="form-label"></label>
<div class="form-input">
<button class="submit" type="submit">Delete account</button>
<button type="submit">Delete account</button>
</div>
</div>
</form>

View file

@ -1,74 +1,94 @@
<form class="account-settings">
<div class="form-row">
<label class="form-label">User picture:</label>
<div class="form-input">
<label for="account-settings-avatar-gravatar">
<input type="radio" name="avatar-style" id="account-settings-avatar-gravatar" class="avatar-style" value="gravatar"/>
Gravatar
</label>
<label for="account-settings-avatar-manual">
<input type="radio" name="avatar-style" id="account-settings-avatar-manual" class="avatar-style" value="manual"/>
Custom
</label>
<label for="account-settings-avatar-none">
<input type="radio" name="avatar-style" id="account-settings-avatar-none" class="avatar-style" value="none"/>
None
</label>
</div>
</div>
<div class="messages"></div>
<div class="form-row">
<label class="form-label" for="account-settings-avatar-content"></label>
<div class="form-input">
<input class="avatar-content" type="file" name="avatar-content" id="account-settings-avatar-content"/>
</div>
</div>
<div class="form-row">
<label class="form-label" for="account-settings-name">Name:</label>
<% if (canChangeAvatarStyle) { %>
<div class="form-row">
<label class="form-label">User picture:</label>
<div class="form-input">
<input type="text" name="name" id="account-settings-name" placeholder="New name&hellip;" value=""/>
<%
var avatarStyles = {
gravatar: 'Gravatar',
manual: 'Custom',
blank: 'Blank',
};
%>
<% _.each(avatarStyles, function(v, k) { %>
<label for="account-settings-avatar-<%= k %>">
<input <% print(user.avatarStyle == k ? 'checked="checked"' : '') %> type="radio" name="avatar-style" id="account-settings-avatar-<%= k %>" value="<%= k %>"/>
<%= v %>
</label>
<% }) %>
</div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="account-settings-email">E-mail:</label>
<div class="form-input">
<input type="text" name="email" id="account-settings-email" placeholder="New e-mail&hellip;" value=""/>
<div class="form-row avatar-content">
<label class="form-label"></label>
<div class="form-input">
<input type="file" name="avatar-content" id="account-settings-avatar-content"/>
</div>
</div>
</div>
<% } %>
<div class="form-row">
<label class="form-label" for="account-settings-password1">New password:</label>
<div class="form-input">
<input type="password" name="password1" id="account-settings-password1" placeholder="New password&hellip;" value=""/>
<% if (canChangeName) { %>
<div class="form-row">
<label class="form-label" for="account-settings-name">Name:</label>
<div class="form-input">
<input type="text" name="userName" id="account-settings-name" placeholder="New name&hellip;" value="<%= user.name %>"/>
</div>
</div>
</div>
<% } %>
<div class="form-row">
<label class="form-label" for="account-settings-password2"></label>
<div class="form-input">
<input type="password" name="password2" id="account-settings-password2" placeholder="New password&hellip; (repeat)" value=""/>
<% if (canChangeEmailAddress) { %>
<div class="form-row">
<label class="form-label" for="account-settings-email">E-mail:</label>
<div class="form-input">
<input type="text" name="email" id="account-settings-email" placeholder="New e-mail&hellip;" value="<%= user.email %>"/>
</div>
</div>
</div>
<% } %>
<div class="form-row">
<label class="form-label" for="account-settings-access-rank">Access rank:</label>
<div class="form-input">
<select name="access-rank" id="account-settings-access-rank">
<option value="anonymous">anonymous</option>
<option value="regular-user">registered</option>
<option value="power-user">power user</option>
<option value="moderator">moderator</option>
<option value="administrator" selected="selected">admin</option>
</select>
<% if (canChangePassword) { %>
<div class="form-row">
<label class="form-label" for="account-settings-password">New password:</label>
<div class="form-input">
<input type="password" name="password" id="account-settings-password" placeholder="New password&hellip;" value=""/>
</div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="account-settings-password-confirmation"></label>
<div class="form-input">
<input type="password" name="passwordConfirmation" id="account-settings-password-confirmation" placeholder="New password&hellip; (repeat)" value=""/>
</div>
</div>
<% } %>
<% if (canChangeAccessRank) { %>
<div class="form-row">
<label class="form-label" for="account-settings-access-rank">Access rank:</label>
<div class="form-input">
<select name="access-rank" id="account-settings-access-rank">
<%
var accessRanks = {
anonymous: 'Anonymous',
regularUser: 'Regular user',
powerUser: 'Power user',
moderator: 'Moderator',
administrator: 'Administrator'
};
%>
<% _.each(accessRanks, function(v, k) { %>
<option <% print(user.accessRank == k ? 'selected="selected"' : '') %> value="<%= k %>"><%= v %></option>
<% }) %>
</select>
</div>
</div>
<% } %>
<div class="form-row">
<label class="form-label"></label>
<div class="form-input">
<button class="submit" type="submit">Update settings</button>
<button type="submit">Update settings</button>
</div>
</div>
</form>

View file

@ -1,4 +1,6 @@
<form class="browsing-settings">
<div class="messages"></div>
<div class="form-row">
<label class="form-label">Safety:</label>
<div class="form-input">
@ -47,7 +49,7 @@
<div class="form-row">
<label class="form-label"></label>
<div class="form-input">
<button class="submit" type="submit">Update settings</button>
<button type="submit">Update settings</button>
</div>
</div>
</form>

View file

@ -8,14 +8,14 @@
<form method="post" class="form-wrapper">
<div class="form-row">
<label for="login-user" class="form-label">User name:</label>
<label class="form-label" for="login-user">User name:</label>
<div class="form-input">
<input autocomplete="off" type="text" name="user" id="login-user"/>
</div>
</div>
<div class="form-row">
<label for="login-password" class="form-label">Password:</label>
<label class="form-label" for="login-password">Password:</label>
<div class="form-input">
<input autocomplete="off" type="password" name="password" id="login-password"/>
</div>
@ -24,12 +24,11 @@
<div class="form-row">
<label class="form-label"></label>
<div class="form-input">
<button class="submit" type="submit">Log in</button>
<button type="submit">Log in</button>
&nbsp;
<input type="hidden" name="remember" value="0"/>
<label class="checkbox-wrapper">
<label>
<input type="checkbox" name="remember" value="1"/>
<span></span>
Remember me
</label>
</div>

View file

@ -8,28 +8,28 @@
<form method="post" class="form-wrapper">
<div class="form-row">
<label for="registration-user" class="form-label">User name:</label>
<label class="form-label" for="registration-user">User name:</label>
<div class="form-input">
<input autocomplete="off" type="text" name="user" id="registration-user" placeholder="e.g. darth_vader" value=""/>
<input autocomplete="off" type="text" name="userName" id="registration-user" placeholder="e.g. darth_vader" value=""/>
</div>
</div>
<div class="form-row">
<label for="registration-password" class="form-label">Password:</label>
<label class="form-label" for="registration-password">Password:</label>
<div class="form-input">
<input autocomplete="off" type="password" name="password1" id="registration-password" placeholder="e.g. &#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;" value=""/>
<input autocomplete="off" type="password" name="password" id="registration-password" placeholder="e.g. &#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;" value=""/>
</div>
</div>
<div class="form-row">
<label for="registration-password-confirm" class="form-label">Password (repeat):</label>
<label class="form-label" for="registration-password-confirmation">Password (repeat):</label>
<div class="form-input">
<input autocomplete="off" type="password" name="password2" id="registration-password-confirm" placeholder="e.g. &#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;" value=""/>
<input autocomplete="off" type="password" name="passwordConfirmation" id="registration-password-confirmation" placeholder="e.g. &#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;&#x25cf;" value=""/>
</div>
</div>
<div class="form-row">
<label for="registration-email" class="form-label">E-mail address:</label>
<label class="form-label" for="registration-email">E-mail address:</label>
<div class="form-input">
<input autocomplete="off" type="text" name="email" id="registration-email" placeholder="e.g. vader@empire.gov" value=""/>
</div>
@ -38,7 +38,7 @@
<div class="form-row">
<label class="form-label"></label>
<div class="form-input">
<button class="submit" type="submit">Register</button>
<button type="submit">Register</button>
</div>
</div>
</form>

View file

@ -1,16 +1,22 @@
<div id="user-view">
<div class="messages"></div>
<img src="/api/users/<%= user.name %>/avatar/50" alt="Avatar"/>
<%= user.name %>
<h2>Browsing settings</h2>
<div class="browsing-settings"></div>
<% if (canChangeBrowsingSettings) { %>
<h2>Browsing settings</h2>
<div id="browsing-settings-target"></div>
<% } %>
<h2>Account settings</h2>
<div class="account-settings"></div>
<% if (canChangeAccountSettings) { %>
<h2>Account settings</h2>
<div id="account-settings-target"></div>
<% } %>
<% if (canDeleteAccount) { %>
<h2>Account removal</h2>
<div class="account-removal"></div>
<div id="account-removal-target"></div>
<% } %>
</div>

View file

@ -0,0 +1,69 @@
<?php
namespace Szurubooru\Controllers;
final class UserAvatarController extends AbstractController
{
private $userService;
private $fileService;
private $httpHelper;
private $thumbnailService;
public function __construct(
\Szurubooru\Services\UserService $userService,
\Szurubooru\Services\FileService $fileService,
\Szurubooru\Helpers\HttpHelper $httpHelper,
\Szurubooru\Services\ThumbnailService $thumbnailService)
{
$this->userService = $userService;
$this->fileService = $fileService;
$this->httpHelper = $httpHelper;
$this->thumbnailService = $thumbnailService;
}
public function registerRoutes(\Szurubooru\Router $router)
{
$router->get('/api/users/:userName/avatar/:size', [$this, 'getAvatarByName']);
}
public function getAvatarByName($userName, $size)
{
$user = $this->userService->getByName($userName);
if (!$user)
throw new \InvalidArgumentException('User with name "' . $userName . '" was not found.');
switch ($user->avatarStyle)
{
case \Szurubooru\Entities\User::AVATAR_STYLE_GRAVATAR:
$hash = md5(strtolower(trim($user->email ? $user->email : $user->id . $user->name)));
$url = 'https://www.gravatar.com/avatar/' . $hash . '?d=retro&s=' . $size;
$this->serveFromUrl($url);
break;
case \Szurubooru\Entities\User::AVATAR_STYLE_BLANK:
$this->serveFromFile($this->userService->getBlankAvatarSourcePath(), $size);
break;
case \Szurubooru\Entities\User::AVATAR_STYLE_MANUAL:
$this->serveFromFile($this->userService->getCustomAvatarSourcePath($user), $size);
break;
default:
$this->serveFromFile($this->userService->getBlankAvatarSourcePath(), $size);
break;
}
}
private function serveFromUrl($url)
{
$this->httpHelper->nonCachedRedirect($url);
}
private function serveFromFile($file, $size)
{
if (!$this->fileService->exists($file))
$file = $this->userService->getBlankAvatarSourcePath();
$sizedFile = $this->thumbnailService->generateFromFile($file, $size, $size);
$this->fileService->serve($sizedFile);
}
}

View file

@ -22,18 +22,18 @@ final class UserController extends AbstractController
public function registerRoutes(\Szurubooru\Router $router)
{
$router->post('/api/users', [$this, 'register']);
$router->post('/api/users', [$this, 'createUser']);
$router->get('/api/users', [$this, 'getFiltered']);
$router->get('/api/users/:name', [$this, 'getByName']);
$router->put('/api/users/:name', [$this, 'update']);
$router->delete('/api/users/:name', [$this, 'delete']);
$router->get('/api/users/:userName', [$this, 'getByName']);
$router->put('/api/users/:userName', [$this, 'updateUser']);
$router->delete('/api/users/:userName', [$this, 'deleteUser']);
}
public function getByName($name)
public function getByName($userName)
{
$user = $this->userService->getByName($name);
$user = $this->userService->getByName($userName);
if (!$user)
throw new \DomainException('User with name "' . $name . '" was not found.');
throw new \InvalidArgumentException('User with name "' . $userName . '" was not found.');
return $this->userViewProxy->fromEntity($user);
}
@ -41,8 +41,8 @@ final class UserController extends AbstractController
{
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::LIST_USERS);
$searchFormData = new \Szurubooru\FormData\SearchFormData($this->inputReader);
$searchResult = $this->userService->getFiltered($searchFormData);
$formData = new \Szurubooru\FormData\SearchFormData($this->inputReader);
$searchResult = $this->userService->getFiltered($formData);
$entities = $this->userViewProxy->fromArray($searchResult->entities);
return [
'data' => $entities,
@ -50,27 +50,66 @@ final class UserController extends AbstractController
'totalRecords' => $searchResult->totalRecords];
}
public function register()
public function createUser()
{
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::REGISTER);
$input = new \Szurubooru\FormData\RegistrationFormData($this->inputReader);
$user = $this->userService->register($input);
$formData = new \Szurubooru\FormData\RegistrationFormData($this->inputReader);
$user = $this->userService->createUser($formData);
return $this->userViewProxy->fromEntity($user);
}
public function update($name)
public function updateUser($userName)
{
throw new \BadMethodCallException('Not implemented');
$formData = new \Szurubooru\FormData\UserEditFormData($this->inputReader);
if ($formData->avatarStyle !== null)
{
$this->privilegeService->assertPrivilege(
$this->privilegeService->isLoggedIn($userName)
? \Szurubooru\Privilege::CHANGE_OWN_AVATAR_STYLE
: \Szurubooru\Privilege::CHANGE_ALL_AVATAR_STYLES);
}
if ($formData->userName !== null)
{
$this->privilegeService->assertPrivilege(
$this->privilegeService->isLoggedIn($userName)
? \Szurubooru\Privilege::CHANGE_OWN_NAME
: \Szurubooru\Privilege::CHANGE_ALL_NAMES);
}
if ($formData->password !== null)
{
$this->privilegeService->assertPrivilege(
$this->privilegeService->isLoggedIn($userName)
? \Szurubooru\Privilege::CHANGE_OWN_PASSWORD
: \Szurubooru\Privilege::CHANGE_ALL_PASSWORDS);
}
if ($formData->email !== null)
{
$this->privilegeService->assertPrivilege(
$this->privilegeService->isLoggedIn($userName)
? \Szurubooru\Privilege::CHANGE_OWN_EMAIL_ADDRESS
: \Szurubooru\Privilege::CHANGE_ALL_EMAIL_ADDRESSES);
}
if ($formData->accessRank)
{
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::CHANGE_ACCESS_RANK);
}
$user = $this->userService->updateUser($userName, $formData);
return $this->userViewProxy->fromEntity($user);
}
public function delete($name)
public function deleteUser($userName)
{
$this->privilegeService->assertPrivilege(
$this->privilegeService->isLoggedIn($name)
$this->privilegeService->isLoggedIn($userName)
? \Szurubooru\Privilege::DELETE_OWN_ACCOUNT
: \Szurubooru\Privilege::DELETE_ACCOUNTS);
return $this->userService->deleteByName($name);
return $this->userService->deleteUserByName($userName);
}
}

View file

@ -20,8 +20,9 @@ class UserViewProxy extends AbstractViewProxy
$result->accessRank = \Szurubooru\Helpers\EnumHelper::accessRankToString($user->accessRank);
$result->registrationTime = $user->registrationTime;
$result->lastLoginTime = $user->lastLoginTime;
$result->avatarStyle = $user->avatarStyle;
if ($this->privilegeService->hasPrivilege(\Szurubooru\Privilege::PRIVILEGE_VIEW_ALL_EMAIL_ADDRESSES) or
if ($this->privilegeService->hasPrivilege(\Szurubooru\Privilege::VIEW_ALL_EMAIL_ADDRESSES) or
$this->privilegeService->isLoggedIn($user))
{
$result->email = $user->email;

View file

@ -10,10 +10,15 @@ final class User extends Entity
const ACCESS_RANK_MODERATOR = 4;
const ACCESS_RANK_ADMINISTRATOR = 5;
const AVATAR_STYLE_GRAVATAR = 'gravatar';
const AVATAR_STYLE_MANUAL = 'manual';
const AVATAR_STYLE_BLANK = 'blank';
public $name;
public $email;
public $passwordHash;
public $accessRank;
public $registrationTime;
public $lastLoginTime;
public $avatarStyle;
}

View file

@ -3,7 +3,7 @@ namespace Szurubooru\FormData;
class RegistrationFormData
{
public $name;
public $userName;
public $password;
public $email;
@ -11,7 +11,7 @@ class RegistrationFormData
{
if ($inputReader !== null)
{
$this->name = $inputReader->userName;
$this->userName = $inputReader->userName;
$this->password = $inputReader->password;
$this->email = $inputReader->email;
}

View file

@ -0,0 +1,24 @@
<?php
namespace Szurubooru\FormData;
class UserEditFormData
{
public $userName;
public $email;
public $accessRank;
public $password;
public $avatarStyle;
public function __construct($inputReader = null)
{
if ($inputReader !== null)
{
$this->userName = $inputReader->userName;
$this->email = $inputReader->email;
$this->password = $inputReader->password;
$this->accessRank = $inputReader->accessRank;
$this->avatarStyle = $inputReader->avatarStyle;
$this->avatarContent = $inputReader->avatarContent;
}
}
}

View file

@ -7,13 +7,38 @@ class EnumHelper
{
switch ($accessRank)
{
case \Szurubooru\Entities\User::ACCESS_RANK_ANONYMOUS: return 'anonymous'; break;
case \Szurubooru\Entities\User::ACCESS_RANK_REGULAR_USER: return 'regularUser'; break;
case \Szurubooru\Entities\User::ACCESS_RANK_POWER_USER: return 'powerUser'; break;
case \Szurubooru\Entities\User::ACCESS_RANK_MODERATOR: return 'moderator'; break;
case \Szurubooru\Entities\User::ACCESS_RANK_ADMINISTRATOR: return 'administrator'; break;
case \Szurubooru\Entities\User::ACCESS_RANK_ANONYMOUS: return 'anonymous';
case \Szurubooru\Entities\User::ACCESS_RANK_REGULAR_USER: return 'regularUser';
case \Szurubooru\Entities\User::ACCESS_RANK_POWER_USER: return 'powerUser';
case \Szurubooru\Entities\User::ACCESS_RANK_MODERATOR: return 'moderator';
case \Szurubooru\Entities\User::ACCESS_RANK_ADMINISTRATOR: return 'administrator';
default:
throw new \DomainException('Invalid access rank!');
}
}
public static function accessRankFromString($accessRankString)
{
switch (trim(strtolower($accessRankString)))
{
case 'anonymous': return \Szurubooru\Entities\User::ACCESS_RANK_ANONYMOUS;
case 'regularuser': return \Szurubooru\Entities\User::ACCESS_RANK_REGULAR_USER;
case 'poweruser': return \Szurubooru\Entities\User::ACCESS_RANK_POWER_USER;
case 'moderator': return \Szurubooru\Entities\User::ACCESS_RANK_MODERATOR;
case 'administrator': return \Szurubooru\Entities\User::ACCESS_RANK_ADMINISTRATOR;
default:
throw new \DomainException('Unrecognized access rank: ' . $accessRankString);
}
}
public static function avatarStyleFromString($avatarStyleString)
{
switch (trim(strtolower($avatarStyleString)))
{
case 'gravatar': return \Szurubooru\Entities\User::AVATAR_STYLE_GRAVATAR;
case 'manual': return \Szurubooru\Entities\User::AVATAR_STYLE_MANUAL;
case 'none':
case 'blank': return \Szurubooru\Entities\User::AVATAR_STYLE_BLANK;
}
}
}

View file

@ -45,4 +45,10 @@ class HttpHelper
$requestUri = preg_replace('/\?.*$/', '', $requestUri);
return $requestUri;
}
public function nonCachedRedirect($destination)
{
$this->setResponseCode(303);
$this->setHeader('Location', $destination);
}
}

View file

@ -5,6 +5,16 @@ class Privilege
{
const REGISTER = 'register';
const LIST_USERS = 'listUsers';
const VIEW_ALL_EMAIL_ADDRESSES = 'viewAllEmailAddresses';
const CHANGE_ACCESS_RANK = 'changeAccessRank';
const CHANGE_OWN_AVATAR_STYLE = 'changeOwnAvatarStyle';
const CHANGE_OWN_EMAIL_ADDRESS = 'changeOwnEmailAddress';
const CHANGE_OWN_NAME = 'changeOwnName';
const CHANGE_OWN_PASSWORD = 'changeOwnPassword';
const CHANGE_ALL_AVATAR_STYLES = 'changeAllAvatarStyles';
const CHANGE_ALL_EMAIL_ADDRESSES = 'changeAllEmailAddresses';
const CHANGE_ALL_NAMES = 'changeAllNames';
const CHANGE_ALL_PASSWORDS = 'changeAllPasswords';
const DELETE_OWN_ACCOUNT = 'deleteOwnAccount';
const DELETE_ALL_ACCOUNTS = 'deleteAllAccounts';
}

View file

@ -0,0 +1,93 @@
<?php
namespace Szurubooru\Services;
class FileService
{
private $dataDirectory;
private $httpHelper;
public function __construct($dataDirectory, \Szurubooru\Helpers\HttpHelper $httpHelper)
{
$this->dataDirectory = $dataDirectory;
$this->httpHelper = $httpHelper;
}
public function serve($source, $options = [])
{
$finalSource = $this->getFullPath($source);
$daysToLive = isset($options->daysToLive)
? $options->daysToLive
: 7;
$secondsToLive = $daysToLive * 24 * 60 * 60;
$lastModified = filemtime($finalSource);
$eTag = md5(file_get_contents($finalSource)); //todo: faster
$ifModifiedSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])
? $_SERVER['HTTP_IF_MODIFIED_SINCE']
: false;
$eTagHeader = isset($_SERVER['HTTP_IF_NONE_MATCH'])
? trim($_SERVER['HTTP_IF_NONE_MATCH'], "\" \t\r\n")
: false;
$this->httpHelper->setHeader('ETag', '"' . $eTag . '"');
$this->httpHelper->setHeader('Last-Modified', gmdate('D, d M Y H:i:s \G\M\T', $lastModified));
$this->httpHelper->setHeader('Pragma', 'public');
$this->httpHelper->setHeader('Cache-Control', 'public, max-age=' . $secondsToLive);
$this->httpHelper->setHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + $secondsToLive));
if (isset($options->customFileName))
{
$this->httpHelper->setHeader('Content-Disposition', 'inline; filename="' . $options->customFileName . '"');
}
if (isset($options->mimeType))
{
$this->httpHelper->setHeader('Content-Type', $options->mimeType);
}
else
{
$this->httpHelper->setHeader('Content-Type', mime_content_type($finalSource));
}
if (strtotime($ifModifiedSince) == $lastModified or $eTagHeader == $eTag)
{
$this->httpHelper->setResponseCode(304);
}
else
{
$this->httpHelper->setResponseCode(200);
readfile($finalSource);
}
exit;
}
public function exists($source)
{
$finalSource = $this->getFullPath($source);
return $source and file_exists($finalSource);
}
public function delete($source)
{
$finalSource = $this->getFullPath($source);
if (file_exists($finalSource))
unlink($finalSource);
}
public function saveFromBase64($base64string, $destination)
{
$finalDestination = $this->getFullPath($destination);
$commaPosition = strpos($base64string, ',');
if ($commaPosition !== null)
$base64string = substr($base64string, $commaPosition + 1);
$data = base64_decode($base64string);
file_put_contents($finalDestination, $data);
}
public function getFullPath($destination)
{
return $this->dataDirectory . DIRECTORY_SEPARATOR . $destination;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Szurubooru\Services\ThumbnailGenerators;
interface IThumbnailGenerator
{
public function generateFromFile($srcPath, $dstPath, $width, $height);
}

View file

@ -0,0 +1,98 @@
<?php
namespace Szurubooru\Services\ThumbnailGenerators;
class ImageGdThumbnailGenerator implements IThumbnailGenerator
{
private $config;
public function __construct(\Szurubooru\Config $config)
{
$this->config = $config;
}
public function generateFromFile($srcPath, $dstPath, $width, $height)
{
if (!file_exists($srcPath))
throw new \InvalidArgumentException($srcPath . ' does not exist');
$mime = mime_content_type($srcPath);
switch ($mime)
{
case 'image/jpeg':
$srcImage = imagecreatefromjpeg($srcPath);
break;
case 'image/png':
$srcImage = imagecreatefrompng($srcPath);
break;
case 'image/gif':
$srcImage = imagecreatefromgif($srcPath);
break;
default:
throw new \Exception('Invalid thumbnail image type');
}
switch ($this->config->misc->thumbnailCropStyle)
{
case 'outside':
$dstImage = $this->cropOutside($srcImage, $width, $height);
break;
case 'inside':
$dstImage = $this->cropInside($srcImage, $width, $height);
break;
default:
throw new \Exception('Unknown thumbnail crop style');
}
imagejpeg($dstImage, $dstPath);
imagedestroy($srcImage);
imagedestroy($dstImage);
}
private function cropOutside($srcImage, $dstWidth, $dstHeight)
{
$srcWidth = imagesx($srcImage);
$srcHeight = imagesy($srcImage);
if (($dstHeight / $dstWidth) > ($srcHeight / $srcWidth))
{
$h = $srcHeight;
$w = $h * $dstWidth / $dstHeight;
}
else
{
$w = $srcWidth;
$h = $w * $dstHeight / $dstWidth;
}
$x = ($srcWidth - $w) / 2;
$y = ($srcHeight - $h) / 2;
$dstImage = imagecreatetruecolor($dstWidth, $dstHeight);
imagecopyresampled($dstImage, $srcImage, 0, 0, $x, $y, $dstWidth, $dstHeight, $w, $h);
return $dstImage;
}
private function cropInside($srcImage, $dstWidth, $dstHeight)
{
$srcWidth = imagesx($srcImage);
$srcHeight = imagesy($srcImage);
if (($dstHeight / $dstWidth) < ($srcHeight / $srcWidth))
{
$h = $dstHeight;
$w = $h * $srcWidth / $srcHeight;
}
else
{
$w = $dstWidth;
$h = $w * $srcHeight / $srcWidth;
}
$dstImage = imagecreatetruecolor($w, $h);
imagecopyresampled($dstImage, $srcImage, 0, 0, 0, 0, $w, $h, $srcWidth, $srcHeight);
return $dstImage;
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace Szurubooru\Services\ThumbnailGenerators;
class ImageImagickThumbnailGenerator implements IThumbnailGenerator
{
private $config;
public function __construct(\Szurubooru\Config $config)
{
$this->config = $config;
}
public function generateFromFile($srcPath, $dstPath, $width, $height)
{
if (!file_exists($srcPath))
throw new \InvalidArgumentException($srcPath . ' does not exist');
$image = new \Imagick($srcPath);
$image = $image->coalesceImages();
switch ($this->config->misc->thumbnailCropStyle)
{
case 'outside':
$this->cropOutside($image, $width, $height);
break;
case 'inside':
$this->cropInside($image, $width, $height);
break;
default:
throw new \Exception('Unknown thumbnail crop style');
}
$image->writeImage($dstPath);
$image->destroy();
}
private function cropOutside($srcImage, $dstWidth, $dstHeight)
{
$srcWidth = $srcImage->getImageWidth();
$srcHeight = $srcImage->getImageHeight();
if (($dstHeight / $dstWidth) > ($srcHeight / $srcWidth))
{
$h = $dstHeight;
$w = $h * $srcWidth / $srcHeight;
}
else
{
$w = $dstWidth;
$h = $w * $srcHeight / $srcWidth;
}
$x = ($srcWidth - $w) / 2;
$y = ($srcHeight - $h) / 2;
$srcImage->resizeImage($w, $h, \imagick::FILTER_LANCZOS, 0.9);
$srcImage->cropImage($dstWidth, $dstHeight, ($w - $dstWidth) >> 1, ($h - $dstHeight) >> 1);
$srcImage->setImagePage(0, 0, 0, 0);
}
private function cropInside($srcImage, $dstWidth, $dstHeight)
{
$srcWidth = $srcImage->getImageWidth();
$srcHeight = $srcImage->getImageHeight();
if (($dstHeight / $dstWidth) < ($srcHeight / $srcWidth))
{
$h = $dstHeight;
$w = $h * $srcWidth / $srcHeight;
}
else
{
$w = $dstWidth;
$h = $w * $srcHeight / $srcWidth;
}
$srcImage->resizeImage($w, $h, \imagick::FILTER_LANCZOS, 0.9);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Szurubooru\Services\ThumbnailGenerators;
class ImageThumbnailGenerator implements IThumbnailGenerator
{
private $imageImagickThumbnailGenerator;
private $imageGdThumbnailGenerator;
public function __construct(
ImageImagickThumbnailGenerator $imageImagickThumbnailGenerator,
ImageGdThumbnailGenerator $imageGdThumbnailGenerator)
{
$this->imageImagickThumbnailGenerator = $imageImagickThumbnailGenerator;
$this->imageGdThumbnailGenerator = $imageGdThumbnailGenerator;
}
public function generateFromFile($srcPath, $dstPath, $width, $height)
{
if (extension_loaded('imagick'))
$strategy = $this->imageImagickThumbnailGenerator;
elseif (extension_loaded('gd'))
$strategy = $this->imageGdThumbnailGenerator;
else
throw new \Exception('Both imagick and gd extensions are disabled');
return $strategy->generateFromFile($srcPath, $dstPath, $width, $height);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Szurubooru\Services;
class ThumbnailService
{
private $fileService;
private $thumbnailGenerator;
public function __construct(
FileService $fileService,
ThumbnailGenerators\SmartThumbnailGenerator $thumbnailGenerator)
{
$this->fileService = $fileService;
$this->thumbnailGenerator = $thumbnailGenerator;
}
public function generateFromFile($source, $width, $height)
{
$target = $source . '-thumb' . $width . 'x' . $height . '.jpg';
if (!$this->fileService->exists($target))
{
$fullSource = $this->fileService->getFullPath($source);
$fullTarget = $this->fileService->getFullPath($target);
$this->thumbnailGenerator->generateFromFile($fullSource, $fullTarget, $width, $height);
}
return $target;
}
}

View file

@ -9,6 +9,7 @@ class UserService
private $userSearchService;
private $passwordService;
private $emailService;
private $fileService;
private $timeService;
public function __construct(
@ -18,6 +19,7 @@ class UserService
\Szurubooru\Dao\Services\UserSearchService $userSearchService,
\Szurubooru\Services\PasswordService $passwordService,
\Szurubooru\Services\EmailService $emailService,
\Szurubooru\Services\FileService $fileService,
\Szurubooru\Services\TimeService $timeService)
{
$this->config = $config;
@ -26,6 +28,7 @@ class UserService
$this->userSearchService = $userSearchService;
$this->passwordService = $passwordService;
$this->emailService = $emailService;
$this->fileService = $fileService;
$this->timeService = $timeService;
}
@ -42,17 +45,17 @@ class UserService
return $this->userSearchService->getFiltered($searchFilter);
}
public function register(\Szurubooru\FormData\RegistrationFormData $formData)
public function createUser(\Szurubooru\FormData\RegistrationFormData $formData)
{
$this->validator->validateUserName($formData->name);
$this->validator->validateUserName($formData->userName);
$this->validator->validatePassword($formData->password);
$this->validator->validateEmail($formData->email);
if ($this->userDao->getByName($formData->name))
if ($this->userDao->getByName($formData->userName))
throw new \DomainException('User with this name already exists.');
$user = new \Szurubooru\Entities\User();
$user->name = $formData->name;
$user->name = $formData->userName;
$user->email = $formData->email;
$user->passwordHash = $this->passwordService->getHash($formData->password);
$user->accessRank = $this->userDao->hasAnyUsers()
@ -60,15 +63,81 @@ class UserService
: \Szurubooru\Entities\User::ACCESS_RANK_ADMINISTRATOR;
$user->registrationTime = $this->timeService->getCurrentTime();
$user->lastLoginTime = null;
$user->avatarStyle = \Szurubooru\Entities\User::AVATAR_STYLE_GRAVATAR;
//todo: send activation mail if necessary
$this->sendActivationMailIfNeeded($user);
return $this->userDao->save($user);
}
public function deleteByName($name)
public function updateUser($userName, \Szurubooru\FormData\UserEditFormData $formData)
{
$this->userDao->deleteByName($name);
$user = $this->getByName($userName);
if (!$user)
throw new \InvalidArgumentException('User with name "' . $userName . '" was not found.');
if ($formData->avatarStyle !== null)
{
$user->avatarStyle = \Szurubooru\Helpers\EnumHelper::avatarStyleFromString($formData->avatarStyle);
if ($formData->avatarContent)
$this->fileService->saveFromBase64($formData->avatarContent, $this->getCustomAvatarSourcePath($user));
}
if ($formData->userName !== null and $formData->userName != $user->name)
{
$this->validator->validateUserName($formData->userName);
if ($this->userDao->getByName($formData->userName))
throw new \DomainException('User with this name already exists.');
$user->name = $formData->userName;
}
if ($formData->password !== null)
{
$this->validator->validatePassword($formData->password);
$user->passwordHash = $this->passwordService->getHash($formData->password);
}
if ($formData->email !== null)
{
$this->validator->validateEmail($formData->email);
$user->email = $formData->email;
}
if ($formData->accessRank !== null)
{
$user->accessRank = \Szurubooru\Helpers\EnumHelper::accessRankFromString($formData->accessRank);
}
if ($formData->email !== null)
$this->sendActivationMailIfNeeded($user);
return $this->userDao->save($user);
}
public function deleteUserByName($userName)
{
$user = $this->getByName($userName);
if (!$user)
throw new \InvalidArgumentException('User with name "' . $userName . '" was not found.');
$this->userDao->deleteByName($userName);
$this->fileService->delete($this->getCustomAvatarSourcePath($user));
return true;
}
public function getCustomAvatarSourcePath($user)
{
return 'avatars' . DIRECTORY_SEPARATOR . $user->id;
}
public function getBlankAvatarSourcePath()
{
return 'avatars' . DIRECTORY_SEPARATOR . 'blank.png';
}
private function sendActivationMailIfNeeded(\Szurubooru\Entities\User &$user)
{
//todo
}
}

View file

@ -1,8 +1,11 @@
<?php
$dataDirectory = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'data';
return [
\Szurubooru\Config::class => DI\object()->constructor([
__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'config.ini',
__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'local.ini']),
$dataDirectory . DIRECTORY_SEPARATOR . 'config.ini',
$dataDirectory . DIRECTORY_SEPARATOR . 'local.ini']),
\Szurubooru\Services\FileService::class => DI\object()->constructor($dataDirectory),
\Szurubooru\ControllerRepository::class => DI\object()->constructor(DI\link('controllers')),
@ -10,6 +13,7 @@ return [
return [
$c->get(\Szurubooru\Controllers\AuthController::class),
$c->get(\Szurubooru\Controllers\UserController::class),
$c->get(\Szurubooru\Controllers\UserAvatarController::class),
];
}),
];

View file

@ -12,6 +12,23 @@ abstract class AbstractTestCase extends \PHPUnit_Framework_TestCase
{
return new ConfigMock();
}
public function getTestDirectory()
{
return __DIR__ . DIRECTORY_SEPARATOR . 'files';
}
protected function tearDown()
{
$this->cleanTestDirectory();
}
private function cleanTestDirectory()
{
foreach (scandir($this->getTestDirectory()) as $fn)
if ($fn{0} != '.')
unlink($this->getTestDirectory() . DIRECTORY_SEPARATOR . $fn);
}
}
date_default_timezone_set('UTC');

View file

@ -0,0 +1,16 @@
<?php
namespace Szurubooru\Tests\Services;
class FileServiceTest extends \Szurubooru\Tests\AbstractTestCase
{
public function testSaving()
{
$httpHelper = $this->mock( \Szurubooru\Helpers\HttpHelper::class);
$fileService = new \Szurubooru\Services\FileService($this->getTestDirectory(), $httpHelper);
$input = 'data:text/plain,YXdlc29tZSBkb2c=';
$fileService->saveFromBase64($input, 'dog.txt');
$expected = 'awesome dog';
$actual = file_get_contents($this->getTestDirectory() . DIRECTORY_SEPARATOR . 'dog.txt');
$this->assertEquals($expected, $actual);
}
}

View file

@ -9,6 +9,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
private $userSearchServiceMock;
private $passwordServiceMock;
private $emailServiceMock;
private $fileServiceMock;
private $timeServiceMock;
public function setUp()
@ -19,6 +20,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
$this->userSearchService = $this->mock(\Szurubooru\Dao\Services\UserSearchService::class);
$this->passwordServiceMock = $this->mock(\Szurubooru\Services\PasswordService::class);
$this->emailServiceMock = $this->mock(\Szurubooru\Services\EmailService::class);
$this->fileServiceMock = $this->mock(\Szurubooru\Services\FileService::class);
$this->timeServiceMock = $this->mock(\Szurubooru\Services\TimeService::class);
}
@ -43,7 +45,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
public function testValidRegistration()
{
$formData = new \Szurubooru\FormData\RegistrationFormData;
$formData->name = 'user';
$formData->userName = 'user';
$formData->password = 'password';
$formData->email = 'email';
@ -53,7 +55,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
$this->userDaoMock->method('save')->will($this->returnArgument(0));
$userService = $this->getUserService();
$savedUser = $userService->register($formData);
$savedUser = $userService->createUser($formData);
$this->assertEquals('user', $savedUser->name);
$this->assertEquals('email', $savedUser->email);
@ -65,7 +67,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
public function testAccessRankOfFirstUser()
{
$formData = new \Szurubooru\FormData\RegistrationFormData;
$formData->name = 'user';
$formData->userName = 'user';
$formData->password = 'password';
$formData->email = 'email';
@ -73,7 +75,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
$this->userDaoMock->method('save')->will($this->returnArgument(0));
$userService = $this->getUserService();
$savedUser = $userService->register($formData);
$savedUser = $userService->createUser($formData);
$this->assertEquals(\Szurubooru\Entities\User::ACCESS_RANK_ADMINISTRATOR, $savedUser->accessRank);
}
@ -81,7 +83,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
public function testRegistrationWhenUserExists()
{
$formData = new \Szurubooru\FormData\RegistrationFormData;
$formData->name = 'user';
$formData->userName = 'user';
$formData->password = 'password';
$formData->email = 'email';
@ -92,7 +94,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
$userService = $this->getUserService();
$this->setExpectedException(\Exception::class, 'User with this name already exists');
$savedUser = $userService->register($formData);
$savedUser = $userService->createUser($formData);
}
private function getUserService()
@ -104,6 +106,7 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase
$this->userSearchService,
$this->passwordServiceMock,
$this->emailServiceMock,
$this->fileServiceMock,
$this->timeServiceMock);
}
}

2
tests/files/.gitignore vendored Normal file
View file

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