server/users: add avatar support
This commit is contained in:
parent
403cfbd679
commit
e8aeb11081
11
API.md
11
API.md
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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. '''
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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))
|
||||||
|
|
Loading…
Reference in New Issue