From e8aeb110814e8f764a3b4d7eeaaae17b26787f88 Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 9 Apr 2016 21:41:10 +0200 Subject: [PATCH] server/users: add avatar support --- API.md | 11 +++-- INSTALL.md | 4 ++ config.yaml.dist | 11 +++-- server/szurubooru/api/user_api.py | 13 +++-- server/szurubooru/app.py | 4 ++ server/szurubooru/config.py | 9 ++++ server/szurubooru/db/user.py | 7 +-- server/szurubooru/errors.py | 3 ++ .../szurubooru/middleware/json_translator.py | 5 +- server/szurubooru/tests/api/test_user_api.py | 11 ++--- server/szurubooru/util/files.py | 8 ++++ server/szurubooru/util/images.py | 48 +++++++++++++++++++ server/szurubooru/util/users.py | 27 +++++++---- 13 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 server/szurubooru/util/files.py create mode 100644 server/szurubooru/util/images.py diff --git a/API.md b/API.md index 31e0b49..332a34c 100644 --- a/API.md +++ b/API.md @@ -163,9 +163,11 @@ Input: "name": , "password": , "email": , - "rank": + "rank": , + "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 diff --git a/INSTALL.md b/INSTALL.md index 04b5cc9..f3d4dd1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 diff --git a/config.yaml.dist b/config.yaml.dist index 8491ea5..6cb3599 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -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 diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index bae3cb6..227c6b1 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -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)} diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 1d33e08..bd870ac 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -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) diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py index 38637c3..bc9c641 100644 --- a/server/szurubooru/config.py +++ b/server/szurubooru/config.py @@ -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( diff --git a/server/szurubooru/db/user.py b/server/szurubooru/db/user.py index 663ddc8..a8bb004 100644 --- a/server/szurubooru/db/user.py +++ b/server/szurubooru/db/user.py @@ -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) diff --git a/server/szurubooru/errors.py b/server/szurubooru/errors.py index 90b8c65..c5481df 100644 --- a/server/szurubooru/errors.py +++ b/server/szurubooru/errors.py @@ -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. ''' diff --git a/server/szurubooru/middleware/json_translator.py b/server/szurubooru/middleware/json_translator.py index c16e8a3..a852ee4 100644 --- a/server/szurubooru/middleware/json_translator.py +++ b/server/szurubooru/middleware/json_translator.py @@ -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') diff --git a/server/szurubooru/tests/api/test_user_api.py b/server/szurubooru/tests/api/test_user_api.py index 5a5dab8..4614f3b 100644 --- a/server/szurubooru/tests/api/test_user_api.py +++ b/server/szurubooru/tests/api/test_user_api.py @@ -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', diff --git a/server/szurubooru/util/files.py b/server/szurubooru/util/files.py new file mode 100644 index 0000000..7091f88 --- /dev/null +++ b/server/szurubooru/util/files.py @@ -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) diff --git a/server/szurubooru/util/images.py b/server/szurubooru/util/images.py new file mode 100644 index 0000000..17b2a74 --- /dev/null +++ b/server/szurubooru/util/images.py @@ -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 diff --git a/server/szurubooru/util/users.py b/server/szurubooru/util/users.py index 7b7d52f..1f1765b 100644 --- a/server/szurubooru/util/users.py +++ b/server/szurubooru/util/users.py @@ -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))