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

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