server/users: add avatar support

This commit is contained in:
rr- 2016-04-09 21:41:10 +02:00
parent 403cfbd679
commit e8aeb11081
13 changed files with 130 additions and 31 deletions

11
API.md
View file

@ -163,9 +163,11 @@ Input:
"name": <user-name>,
"password": <user-password>,
"email": <email>,
"rank": <rank>
"rank": <rank>,
"avatar_style": <avatar-style>
}
```
Files: `avatar` - the content of the new avatar.
Output:
```json5
{
@ -176,12 +178,15 @@ Output:
Errors: if the user does not exist, or the user with new name already exists
(names are case insensitive), or either of user name, password, email or rank
are invalid, or the user is trying to update their or someone else's rank to
higher than their own, or privileges are too low.
higher than their own, or privileges are too low, or avatar is missing for
manual avatar style.
Updates an existing user using specified parameters. Names and passwords must
match `user_name_regex` and `password_regex` from server's configuration,
respectively. All fields are optional - update concerns only provided fields.
To update last login time, see [authentication](#authentication).
To update last login time, see [authentication](#authentication). Avatar style
can be either `gravatar` or `manual`. `manual` avatar style requires client to
pass also `avatar` file - see [file uploads](#file-uploads) for details.
### Getting user

View file

@ -7,12 +7,16 @@ distributions are different, the steps stay roughly the same.
user@host:~$ sudo pacman -S postgres
user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm
user@host:~$ sudo pip install virtualenv
user@host:~$ python --version
Python 3.5.1
```
The reason `ffmpeg` is used over, say, `ImageMagick` or even `PIL` is because of
Flash and video posts.
### Setting up a database

View file

@ -5,10 +5,15 @@ name: szurubooru
debug: 0
secret: change
api_url: # where frontend connects to, example: http://api.example.com/
base_url: # used to form absolute links, example: http://example.com/
base_url: # used to form links to frontend, example: http://example.com/
data_url: # used to form links to posts and avatars, example: http://example.com/data/
data_dir: # absolute path for posts and avatars storage, example: /srv/www/booru/client/public/data/
avatar_thumbnail_size: 200
post_thumbnail_size: 300
thumbnails:
avatar_width: 300
avatar_height: 300
post_width: 300
post_height: 300
database:
schema: postgres

View file

@ -19,8 +19,10 @@ def _serialize_user(authenticated_user, user):
md5.update((user.email or user.name).lower().encode('utf-8'))
digest = md5.hexdigest()
ret['avatarUrl'] = 'http://gravatar.com/avatar/%s?s=%d' % (
digest, config.config['avatar_thumbnail_size'])
# TODO: else construct a link
digest, config.config['thumbnails']['avatar_width'])
else:
ret['avatarUrl'] = '%s/avatars/%s.jpg' % (
config.config['data_url'].rstrip('/'), user.name.lower())
if authenticated_user.user_id == user.user_id:
ret['email'] = user.email
@ -107,7 +109,12 @@ class UserDetailApi(BaseApi):
auth.verify_privilege(context.user, 'users:edit:%s:rank' % infix)
users.update_rank(user, context.request['rank'], context.user)
# TODO: avatar
if 'avatar_style' in context.request:
auth.verify_privilege(context.user, 'users:edit:%s:avatar' % infix)
users.update_avatar(
user,
context.request['avatar_style'],
context.files.get('avatar') or None)
context.session.commit()
return {'user': _serialize_user(context.user, user)}

View file

@ -39,6 +39,9 @@ def _on_integrity_error(ex, _request, _response, _params):
def _on_not_found_error(ex, _request, _response, _params):
raise falcon.HTTPNotFound(title='Not found', description=str(ex))
def _on_processing_error(ex, _request, _response, _params):
raise falcon.HTTPNotFound(title='Processing error', description=str(ex))
def create_app():
''' Create a WSGI compatible App object. '''
engine = sqlalchemy.create_engine(
@ -71,6 +74,7 @@ def create_app():
app.add_error_handler(errors.ValidationError, _on_validation_error)
app.add_error_handler(errors.SearchError, _on_search_error)
app.add_error_handler(errors.NotFoundError, _on_not_found_error)
app.add_error_handler(errors.ProcessingError, _on_processing_error)
app.add_route('/users/', user_list_api)
app.add_route('/user/{user_name}', user_detail_api)

View file

@ -44,6 +44,15 @@ class Config(object):
'Default rank %r is not on the list of known ranks' % (
self['default_rank']))
for key in ['base_url', 'api_url', 'data_url', 'data_dir']:
if not self[key]:
raise errors.ConfigError(
'Service is not configured: %r is missing' % key)
if not os.path.isabs(self['data_dir']):
raise errors.ConfigError(
'data_dir must be an absolute path')
for key in ['schema', 'host', 'port', 'user', 'pass', 'name']:
if not self['database'][key]:
raise errors.ConfigError(

View file

@ -4,8 +4,8 @@ from szurubooru.db.base import Base
class User(Base):
__tablename__ = 'user'
AVATAR_GRAVATAR = 1
AVATAR_MANUAL = 2
AVATAR_GRAVATAR = 'gravatar'
AVATAR_MANUAL = 'manual'
user_id = sa.Column('id', sa.Integer, primary_key=True)
name = sa.Column('name', sa.String(50), nullable=False, unique=True)
@ -15,4 +15,5 @@ class User(Base):
rank = sa.Column('rank', sa.String(32), nullable=False)
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
last_login_time = sa.Column('last_login_time', sa.DateTime)
avatar_style = sa.Column('avatar_style', sa.String(32), nullable=False)
avatar_style = sa.Column(
'avatar_style', sa.String(32), nullable=False, default=AVATAR_GRAVATAR)

View file

@ -15,3 +15,6 @@ class SearchError(RuntimeError):
class NotFoundError(RuntimeError):
''' Error thrown when a resource (usually DB) couldn't be found. '''
class ProcessingError(RuntimeError):
''' Error thrown by things such as thumbnail generator. '''

View file

@ -28,9 +28,8 @@ class JsonTranslator(object):
form = cgi.FieldStorage(fp=request.stream, environ=request.env)
for key in form:
if key != 'metadata':
request.context.files[key] = (
form.getvalue(key),
getattr(form[key], 'filename', None))
_original_file_name = getattr(form[key], 'filename', None)
request.context.files[key] = form.getvalue(key)
body = form.getvalue('metadata')
else:
body = request.stream.read().decode('utf-8')

View file

@ -11,7 +11,7 @@ class TestRetrievingUsers(DatabaseTestCase):
'privileges': {
'users:list': 'regular_user',
},
'avatar_thumbnail_size': 200,
'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {},
})
@ -55,7 +55,7 @@ class TestRetrievingUser(DatabaseTestCase):
'privileges': {
'users:view': 'regular_user',
},
'avatar_thumbnail_size': 200,
'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {},
})
@ -73,7 +73,7 @@ class TestRetrievingUser(DatabaseTestCase):
self.assertEqual(result['user']['rank'], 'regular_user')
self.assertEqual(result['user']['creationTime'], datetime(1997, 1, 1))
self.assertEqual(result['user']['lastLoginTime'], None)
self.assertEqual(result['user']['avatarStyle'], 1) # i.e. integer
self.assertEqual(result['user']['avatarStyle'], 'gravatar')
def test_retrieving_non_existing(self):
self.context.user.rank = 'regular_user'
@ -137,7 +137,7 @@ class TestCreatingUser(DatabaseTestCase):
'user_name_regex': '.{3,}',
'password_regex': '.{3,}',
'default_rank': 'regular_user',
'avatar_thumbnail_size': 200,
'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {},
'privileges': {
@ -214,7 +214,7 @@ class TestUpdatingUser(DatabaseTestCase):
'secret': '',
'user_name_regex': '.{3,}',
'password_regex': '.{3,}',
'avatar_thumbnail_size': 200,
'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {},
'privileges': {
@ -222,7 +222,6 @@ class TestUpdatingUser(DatabaseTestCase):
'users:edit:self:pass': 'regular_user',
'users:edit:self:email': 'regular_user',
'users:edit:self:rank': 'mod',
'users:edit:any:name': 'mod',
'users:edit:any:pass': 'mod',
'users:edit:any:email': 'mod',

View file

@ -0,0 +1,8 @@
import os
from szurubooru import config
def save(path, content):
full_path = os.path.join(config.config['data_dir'], path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'wb') as handle:
handle.write(content)

View file

@ -0,0 +1,48 @@
import subprocess
from szurubooru import errors
_SCALE_FIT_FMT = \
r'scale=iw*max({width}/iw\,{height}/ih):ih*max({width}/iw\,{height}/ih)'
class Image(object):
def __init__(self, content):
self.content = content
def resize_fill(self, width, height):
self.content = self._execute([
'-i', '-',
'-f', 'image2',
'-vf', _SCALE_FIT_FMT.format(width=width, height=height),
'-vframes', '1',
'-vcodec', 'png',
'-',
])
def to_png(self):
return self._execute([
'-i', '-',
'-f', 'image2',
'-vframes', '1',
'-vcodec', 'png',
'-',
])
def to_jpeg(self):
return self._execute([
'-i', '-',
'-f', 'image2',
'-vframes', '1',
'-vcodec', 'mjpeg',
'-',
])
def _execute(self, cli):
proc = subprocess.Popen(
['ffmpeg'] + cli,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE)
out, err = proc.communicate(input=self.content)
if proc.returncode != 0:
raise errors.ConversionError(err)
return out

View file

@ -2,10 +2,9 @@ import re
from datetime import datetime
from sqlalchemy import func
from szurubooru import config, db, errors
from szurubooru.util import auth, misc
from szurubooru.util import auth, misc, files, images
def create_user(session, name, password, email):
''' Create an user with given parameters and returns it. '''
user = db.User()
update_name(user, name)
update_password(user, password)
@ -19,7 +18,6 @@ def create_user(session, name, password, email):
return user
def update_name(user, name):
''' Validate and update user's name. '''
name = name.strip()
name_regex = config.config['user_name_regex']
if not re.match(name_regex, name):
@ -28,7 +26,6 @@ def update_name(user, name):
user.name = name
def update_password(user, password):
''' Validate and update user's password. '''
password_regex = config.config['password_regex']
if not re.match(password_regex, password):
raise errors.ValidationError(
@ -37,7 +34,6 @@ def update_password(user, password):
user.password_hash = auth.get_password_hash(user.password_salt, password)
def update_email(user, email):
''' Validate and update user's email. '''
email = email.strip() or None
if not misc.is_valid_email(email):
raise errors.ValidationError(
@ -52,28 +48,39 @@ def update_rank(user, rank, authenticated_user):
'Bad rank %r. Valid ranks: %r' % (rank, available_ranks))
if available_ranks.index(authenticated_user.rank) \
< available_ranks.index(rank):
raise errors.AuthError('Trying to set higher rank than your own')
raise errors.AuthError('Trying to set higher rank than your own.')
user.rank = rank
def update_avatar(user, avatar_style, avatar_content):
if avatar_style == 'gravatar':
user.avatar_style = user.AVATAR_GRAVATAR
elif avatar_style == 'manual':
user.avatar_style = user.AVATAR_MANUAL
if not avatar_content:
raise errors.ValidationError('Avatar content missing.')
image = images.Image(avatar_content)
image.resize_fill(
int(config.config['thumbnails']['avatar_width']),
int(config.config['thumbnails']['avatar_height']))
files.save('avatars/' + user.name.lower() + '.jpg', image.to_jpeg())
else:
raise errors.ValidationError('Unknown avatar style: %r' % avatar_style)
def bump_login_time(user):
''' Update user's login time to current date. '''
user.last_login_time = datetime.now()
def reset_password(user):
''' Reset password for an user. '''
password = auth.create_password()
user.password_salt = auth.create_password()
user.password_hash = auth.get_password_hash(user.password_salt, password)
return password
def get_by_name(session, name):
''' Retrieve an user by its name. '''
return session.query(db.User) \
.filter(func.lower(db.User.name) == func.lower(name)) \
.first()
def get_by_name_or_email(session, name_or_email):
''' Retrieve an user by its name or email. '''
return session.query(db.User) \
.filter(
(func.lower(db.User.name) == func.lower(name_or_email))