server/users: add avatar support
This commit is contained in:
parent
403cfbd679
commit
e8aeb11081
13 changed files with 130 additions and 31 deletions
11
API.md
11
API.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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. '''
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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',
|
||||
|
|
8
server/szurubooru/util/files.py
Normal file
8
server/szurubooru/util/files.py
Normal 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)
|
48
server/szurubooru/util/images.py
Normal file
48
server/szurubooru/util/images.py
Normal 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
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue