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>, "name": <user-name>,
"password": <user-password>, "password": <user-password>,
"email": <email>, "email": <email>,
"rank": <rank> "rank": <rank>,
"avatar_style": <avatar-style>
} }
``` ```
Files: `avatar` - the content of the new avatar.
Output: Output:
```json5 ```json5
{ {
@ -176,12 +178,15 @@ Output:
Errors: if the user does not exist, or the user with new name already exists 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 (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 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 Updates an existing user using specified parameters. Names and passwords must
match `user_name_regex` and `password_regex` from server's configuration, match `user_name_regex` and `password_regex` from server's configuration,
respectively. All fields are optional - update concerns only provided fields. 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 ### 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 postgres
user@host:~$ sudo pacman -S python user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm user@host:~$ sudo pacman -S npm
user@host:~$ sudo pip install virtualenv user@host:~$ sudo pip install virtualenv
user@host:~$ python --version user@host:~$ python --version
Python 3.5.1 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 ### Setting up a database

View File

@ -5,10 +5,15 @@ name: szurubooru
debug: 0 debug: 0
secret: change secret: change
api_url: # where frontend connects to, example: http://api.example.com/ 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 thumbnails:
post_thumbnail_size: 300 avatar_width: 300
avatar_height: 300
post_width: 300
post_height: 300
database: database:
schema: postgres 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')) md5.update((user.email or user.name).lower().encode('utf-8'))
digest = md5.hexdigest() digest = md5.hexdigest()
ret['avatarUrl'] = 'http://gravatar.com/avatar/%s?s=%d' % ( ret['avatarUrl'] = 'http://gravatar.com/avatar/%s?s=%d' % (
digest, config.config['avatar_thumbnail_size']) digest, config.config['thumbnails']['avatar_width'])
# TODO: else construct a link else:
ret['avatarUrl'] = '%s/avatars/%s.jpg' % (
config.config['data_url'].rstrip('/'), user.name.lower())
if authenticated_user.user_id == user.user_id: if authenticated_user.user_id == user.user_id:
ret['email'] = user.email ret['email'] = user.email
@ -107,7 +109,12 @@ class UserDetailApi(BaseApi):
auth.verify_privilege(context.user, 'users:edit:%s:rank' % infix) auth.verify_privilege(context.user, 'users:edit:%s:rank' % infix)
users.update_rank(user, context.request['rank'], context.user) 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() context.session.commit()
return {'user': _serialize_user(context.user, user)} 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): def _on_not_found_error(ex, _request, _response, _params):
raise falcon.HTTPNotFound(title='Not found', description=str(ex)) 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(): def create_app():
''' Create a WSGI compatible App object. ''' ''' Create a WSGI compatible App object. '''
engine = sqlalchemy.create_engine( 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.ValidationError, _on_validation_error)
app.add_error_handler(errors.SearchError, _on_search_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.NotFoundError, _on_not_found_error)
app.add_error_handler(errors.ProcessingError, _on_processing_error)
app.add_route('/users/', user_list_api) app.add_route('/users/', user_list_api)
app.add_route('/user/{user_name}', user_detail_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' % ( 'Default rank %r is not on the list of known ranks' % (
self['default_rank'])) 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']: for key in ['schema', 'host', 'port', 'user', 'pass', 'name']:
if not self['database'][key]: if not self['database'][key]:
raise errors.ConfigError( raise errors.ConfigError(

View File

@ -4,8 +4,8 @@ from szurubooru.db.base import Base
class User(Base): class User(Base):
__tablename__ = 'user' __tablename__ = 'user'
AVATAR_GRAVATAR = 1 AVATAR_GRAVATAR = 'gravatar'
AVATAR_MANUAL = 2 AVATAR_MANUAL = 'manual'
user_id = sa.Column('id', sa.Integer, primary_key=True) user_id = sa.Column('id', sa.Integer, primary_key=True)
name = sa.Column('name', sa.String(50), nullable=False, unique=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) rank = sa.Column('rank', sa.String(32), nullable=False)
creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) creation_time = sa.Column('creation_time', sa.DateTime, nullable=False)
last_login_time = sa.Column('last_login_time', sa.DateTime) 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): class NotFoundError(RuntimeError):
''' Error thrown when a resource (usually DB) couldn't be found. ''' ''' 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) form = cgi.FieldStorage(fp=request.stream, environ=request.env)
for key in form: for key in form:
if key != 'metadata': if key != 'metadata':
request.context.files[key] = ( _original_file_name = getattr(form[key], 'filename', None)
form.getvalue(key), request.context.files[key] = form.getvalue(key)
getattr(form[key], 'filename', None))
body = form.getvalue('metadata') body = form.getvalue('metadata')
else: else:
body = request.stream.read().decode('utf-8') body = request.stream.read().decode('utf-8')

View File

@ -11,7 +11,7 @@ class TestRetrievingUsers(DatabaseTestCase):
'privileges': { 'privileges': {
'users:list': 'regular_user', 'users:list': 'regular_user',
}, },
'avatar_thumbnail_size': 200, 'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {}, 'rank_names': {},
}) })
@ -55,7 +55,7 @@ class TestRetrievingUser(DatabaseTestCase):
'privileges': { 'privileges': {
'users:view': 'regular_user', 'users:view': 'regular_user',
}, },
'avatar_thumbnail_size': 200, 'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {}, 'rank_names': {},
}) })
@ -73,7 +73,7 @@ class TestRetrievingUser(DatabaseTestCase):
self.assertEqual(result['user']['rank'], 'regular_user') self.assertEqual(result['user']['rank'], 'regular_user')
self.assertEqual(result['user']['creationTime'], datetime(1997, 1, 1)) self.assertEqual(result['user']['creationTime'], datetime(1997, 1, 1))
self.assertEqual(result['user']['lastLoginTime'], None) 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): def test_retrieving_non_existing(self):
self.context.user.rank = 'regular_user' self.context.user.rank = 'regular_user'
@ -137,7 +137,7 @@ class TestCreatingUser(DatabaseTestCase):
'user_name_regex': '.{3,}', 'user_name_regex': '.{3,}',
'password_regex': '.{3,}', 'password_regex': '.{3,}',
'default_rank': 'regular_user', 'default_rank': 'regular_user',
'avatar_thumbnail_size': 200, 'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {}, 'rank_names': {},
'privileges': { 'privileges': {
@ -214,7 +214,7 @@ class TestUpdatingUser(DatabaseTestCase):
'secret': '', 'secret': '',
'user_name_regex': '.{3,}', 'user_name_regex': '.{3,}',
'password_regex': '.{3,}', 'password_regex': '.{3,}',
'avatar_thumbnail_size': 200, 'thumbnails': {'avatar_width': 200},
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'rank_names': {}, 'rank_names': {},
'privileges': { 'privileges': {
@ -222,7 +222,6 @@ class TestUpdatingUser(DatabaseTestCase):
'users:edit:self:pass': 'regular_user', 'users:edit:self:pass': 'regular_user',
'users:edit:self:email': 'regular_user', 'users:edit:self:email': 'regular_user',
'users:edit:self:rank': 'mod', 'users:edit:self:rank': 'mod',
'users:edit:any:name': 'mod', 'users:edit:any:name': 'mod',
'users:edit:any:pass': 'mod', 'users:edit:any:pass': 'mod',
'users:edit:any:email': '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 datetime import datetime
from sqlalchemy import func from sqlalchemy import func
from szurubooru import config, db, errors 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): def create_user(session, name, password, email):
''' Create an user with given parameters and returns it. '''
user = db.User() user = db.User()
update_name(user, name) update_name(user, name)
update_password(user, password) update_password(user, password)
@ -19,7 +18,6 @@ def create_user(session, name, password, email):
return user return user
def update_name(user, name): def update_name(user, name):
''' Validate and update user's name. '''
name = name.strip() name = name.strip()
name_regex = config.config['user_name_regex'] name_regex = config.config['user_name_regex']
if not re.match(name_regex, name): if not re.match(name_regex, name):
@ -28,7 +26,6 @@ def update_name(user, name):
user.name = name user.name = name
def update_password(user, password): def update_password(user, password):
''' Validate and update user's password. '''
password_regex = config.config['password_regex'] password_regex = config.config['password_regex']
if not re.match(password_regex, password): if not re.match(password_regex, password):
raise errors.ValidationError( raise errors.ValidationError(
@ -37,7 +34,6 @@ def update_password(user, password):
user.password_hash = auth.get_password_hash(user.password_salt, password) user.password_hash = auth.get_password_hash(user.password_salt, password)
def update_email(user, email): def update_email(user, email):
''' Validate and update user's email. '''
email = email.strip() or None email = email.strip() or None
if not misc.is_valid_email(email): if not misc.is_valid_email(email):
raise errors.ValidationError( raise errors.ValidationError(
@ -52,28 +48,39 @@ def update_rank(user, rank, authenticated_user):
'Bad rank %r. Valid ranks: %r' % (rank, available_ranks)) 'Bad rank %r. Valid ranks: %r' % (rank, available_ranks))
if available_ranks.index(authenticated_user.rank) \ if available_ranks.index(authenticated_user.rank) \
< available_ranks.index(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 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): def bump_login_time(user):
''' Update user's login time to current date. '''
user.last_login_time = datetime.now() user.last_login_time = datetime.now()
def reset_password(user): def reset_password(user):
''' Reset password for an user. '''
password = auth.create_password() password = auth.create_password()
user.password_salt = auth.create_password() user.password_salt = auth.create_password()
user.password_hash = auth.get_password_hash(user.password_salt, password) user.password_hash = auth.get_password_hash(user.password_salt, password)
return password return password
def get_by_name(session, name): def get_by_name(session, name):
''' Retrieve an user by its name. '''
return session.query(db.User) \ return session.query(db.User) \
.filter(func.lower(db.User.name) == func.lower(name)) \ .filter(func.lower(db.User.name) == func.lower(name)) \
.first() .first()
def get_by_name_or_email(session, name_or_email): def get_by_name_or_email(session, name_or_email):
''' Retrieve an user by its name or email. '''
return session.query(db.User) \ return session.query(db.User) \
.filter( .filter(
(func.lower(db.User.name) == func.lower(name_or_email)) (func.lower(db.User.name) == func.lower(name_or_email))