split files into client/ and server/

This commit is contained in:
rr-
2016-04-01 18:45:25 +02:00
parent 1ad71585c4
commit e487adcc97
72 changed files with 55 additions and 46 deletions

39
server/alembic.ini Normal file
View File

@ -0,0 +1,39 @@
[alembic]
script_location = szurubooru/migrations
# overriden from within config.ini
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

26
server/host-waitress Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
'''
Script facade for direct execution with waitress WSGI server.
Note that szurubooru can be also run using ``python -m szurubooru``, when in
the repository's root directory.
'''
import argparse
import os.path
import sys
import waitress
from szurubooru.app import create_app
def main():
parser = argparse.ArgumentParser('Starts szurubooru using waitress.')
parser.add_argument(
'-p', '--port', type=int, help='port to listen on', default=6666)
parser.add_argument('--host', help='IP to listen on', default='0.0.0.0')
args = parser.parse_args()
app = create_app()
waitress.serve(app, host=args.host, port=args.port)
if __name__ == '__main__':
main()

5
server/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
alembic>=0.8.5
configobj>=5.0.6
falcon>=0.3.0
psycopg2>=2.6.1
SQLAlchemy>=1.0.12

View File

View File

@ -0,0 +1,3 @@
''' Falcon-compatible API facades. '''
from szurubooru.api.users import UserListApi, UserDetailApi

View File

@ -0,0 +1,81 @@
''' Users public API. '''
import re
import falcon
from szurubooru.services.errors import IntegrityError
def _serialize_user(user):
return {
'id': user.user_id,
'name': user.name,
'email': user.email, # TODO: secure this
'accessRank': user.access_rank,
'creationTime': user.creation_time,
'lastLoginTime': user.last_login_time,
'avatarStyle': user.avatar_style
}
class UserListApi(object):
''' API for lists of users. '''
def __init__(self, config, auth_service, user_service):
self._config = config
self._auth_service = auth_service
self._user_service = user_service
def on_get(self, request, response):
''' Retrieves a list of users. '''
self._auth_service.verify_privilege(request.context.user, 'users:list')
request.context.result = {'message': 'Searching for users'}
def on_post(self, request, response):
''' Creates a new user. '''
self._auth_service.verify_privilege(request.context.user, 'users:create')
name_regex = self._config['service']['user_name_regex']
password_regex = self._config['service']['password_regex']
try:
name = request.context.request['name']
password = request.context.request['password']
email = request.context.request['email'].strip()
if not email:
email = None
except KeyError as ex:
raise falcon.HTTPBadRequest(
'Malformed data', 'Field %r not found' % ex.args[0])
if not re.match(name_regex, name):
raise falcon.HTTPBadRequest(
'Malformed data',
'Name must validate %r expression' % name_regex)
if not re.match(password_regex, password):
raise falcon.HTTPBadRequest(
'Malformed data',
'Password must validate %r expression' % password_regex)
session = request.context.session
try:
user = self._user_service.create_user(session, name, password, email)
session.commit()
except:
raise IntegrityError('User %r already exists.' % name)
request.context.result = {'user': _serialize_user(user)}
class UserDetailApi(object):
''' API for individual users. '''
def __init__(self, config, auth_service, user_service):
self._config = config
self._auth_service = auth_service
self._user_service = user_service
def on_get(self, request, response, user_name):
''' Retrieves an user. '''
self._auth_service.verify_privilege(request.context.user, 'users:view')
session = request.context.session
user = self._user_service.get_by_name(session, user_name)
request.context.result = {'user': _serialize_user(user)}
def on_put(self, request, response, user_name):
''' Updates an existing user. '''
self._auth_service.verify_privilege(request.context.user, 'users:edit')
request.context.result = {'message': 'Updating user ' + user_name}

62
server/szurubooru/app.py Normal file
View File

@ -0,0 +1,62 @@
''' Exports create_app. '''
import os
import falcon
import sqlalchemy
import sqlalchemy.orm
import szurubooru.api
import szurubooru.config
import szurubooru.middleware
import szurubooru.services
import szurubooru.util
class _CustomRequest(falcon.Request):
context_type = szurubooru.util.dotdict
def _on_auth_error(ex, req, resp, params):
raise falcon.HTTPForbidden('Authentication error', str(ex))
def _on_integrity_error(ex, req, resp, params):
raise falcon.HTTPConflict('Integrity violation', ex.args[0])
def create_app():
''' Creates a WSGI compatible App object. '''
config = szurubooru.config.Config()
root_dir = os.path.dirname(__file__)
static_dir = os.path.join(root_dir, os.pardir, 'static')
engine = sqlalchemy.create_engine(
'{schema}://{user}:{password}@{host}:{port}/{name}'.format(
schema=config['database']['schema'],
user=config['database']['user'],
password=config['database']['pass'],
host=config['database']['host'],
port=config['database']['port'],
name=config['database']['name']))
session_maker = sqlalchemy.orm.sessionmaker(bind=engine)
scoped_session = sqlalchemy.orm.scoped_session(session_maker)
# TODO: is there a better way?
password_service = szurubooru.services.PasswordService(config)
auth_service = szurubooru.services.AuthService(config, password_service)
user_service = szurubooru.services.UserService(config, password_service)
user_list = szurubooru.api.UserListApi(config, auth_service, user_service)
user = szurubooru.api.UserDetailApi(config, auth_service, user_service)
app = falcon.API(
request_type=_CustomRequest,
middleware=[
szurubooru.middleware.RequireJson(),
szurubooru.middleware.JsonTranslator(),
szurubooru.middleware.DbSession(session_maker),
szurubooru.middleware.Authenticator(auth_service, user_service),
])
app.add_error_handler(szurubooru.services.AuthError, _on_auth_error)
app.add_error_handler(szurubooru.services.IntegrityError, _on_integrity_error)
app.add_route('/users/', user_list)
app.add_route('/user/{user_name}', user)
return app

View File

@ -0,0 +1,38 @@
''' Exports Config. '''
import os
import configobj
class ConfigurationError(RuntimeError):
''' A problem with config.ini file. '''
pass
class Config(object):
''' INI config parser and container. '''
def __init__(self):
self.config = configobj.ConfigObj('../config.ini.dist')
if os.path.exists('../config.ini'):
self.config.merge(configobj.ConfigObj('config.ini'))
self._validate()
def __getitem__(self, key):
return self.config[key]
def _validate(self):
'''
Checks whether config.ini doesn't contain errors that might prove
lethal at runtime.
'''
all_ranks = self['service']['user_ranks']
for privilege, rank in self['privileges'].items():
if rank not in all_ranks:
raise ConfigurationError(
'Rank %r for privilege %r is missing from user_ranks' % (
rank, privilege))
for rank in ['anonymous', 'admin', 'nobody']:
if rank not in all_ranks:
raise ConfigurationError('Fixed rank %r is missing from user_ranks' % rank)
if self['service']['default_user_rank'] not in all_ranks:
raise ConfigurationError(
'Default rank %r is missing from user_ranks' % (
self['service']['default_user_rank']))

View File

@ -0,0 +1,6 @@
''' Various hooks that get executed for each request. '''
from szurubooru.middleware.authenticator import Authenticator
from szurubooru.middleware.json_translator import JsonTranslator
from szurubooru.middleware.require_json import RequireJson
from szurubooru.middleware.db_session import DbSession

View File

@ -0,0 +1,60 @@
''' Exports Authenticator. '''
import base64
import falcon
from szurubooru.model.user import User
from szurubooru.services.errors import AuthError
class Authenticator(object):
'''
Authenticates every request and puts information on active user in the
request context.
'''
def __init__(self, auth_service, user_service):
self._auth_service = auth_service
self._user_service = user_service
def process_request(self, request, response):
''' Executed before passing the request to the API. '''
request.context.user = self._get_user(request)
def _get_user(self, request):
if not request.auth:
return self._create_anonymous_user()
try:
auth_type, user_and_password = request.auth.split(' ', 1)
if auth_type.lower() != 'basic':
raise falcon.HTTPBadRequest(
'Invalid authentication type',
'Only basic authorization is supported.')
username, password = base64.decodebytes(
user_and_password.encode('ascii')).decode('utf8').split(':')
session = request.context.session
return self._authenticate(session, username, password)
except ValueError as err:
msg = 'Basic authentication header value not properly formed. ' \
+ 'Supplied header {0}. Got error: {1}'
raise falcon.HTTPBadRequest(
'Malformed authentication request',
msg.format(request.auth, str(err)))
def _authenticate(self, session, username, password):
''' Tries to authenticate user. Throws AuthError for invalid users. '''
user = self._user_service.get_by_name(session, username)
if not user:
raise AuthError('No such user.')
if not self._auth_service.is_valid_password(user, password):
raise AuthError('Invalid password.')
return user
def _create_anonymous_user(self):
user = User()
user.name = None
user.access_rank = 'anonymous'
user.password = None
return user

View File

@ -0,0 +1,18 @@
''' Exports DbSession. '''
class DbSession(object):
''' Attaches database session to the context of every request. '''
def __init__(self, session_factory):
self._session_factory = session_factory
def process_request(self, request, response):
''' Executed before passing the request to the API. '''
request.context.session = self._session_factory()
def process_response(self, request, response, resource):
'''
Executed before passing the response to falcon.
Any commits to database need to happen explicitly in the API layer.
'''
request.context.session.close()

View File

@ -0,0 +1,44 @@
''' Exports JsonTranslator. '''
import json
from datetime import datetime
import falcon
def json_serial(obj):
''' JSON serializer for objects not serializable by default JSON code '''
if isinstance(obj, datetime):
serial = obj.isoformat()
return serial
raise TypeError('Type not serializable')
class JsonTranslator(object):
'''
Translates API requests and API responses to JSON using requests'
context.
'''
def process_request(self, request, response):
''' Executed before passing the request to the API. '''
if request.content_length in (None, 0):
return
body = request.stream.read()
if not body:
raise falcon.HTTPBadRequest(
'Empty request body',
'A valid JSON document is required.')
try:
request.context.request = json.loads(body.decode('utf-8'))
except (ValueError, UnicodeDecodeError):
raise falcon.HTTPError(
falcon.HTTP_401,
'Malformed JSON',
'Could not decode the request body. The '
'JSON was incorrect or not encoded as UTF-8.')
def process_response(self, request, response, resource):
''' Executed before passing the response to falcon. '''
if 'result' not in request.context:
return
response.body = json.dumps(request.context.result, default=json_serial)

View File

@ -0,0 +1,12 @@
''' Exports RequireJson. '''
import falcon
class RequireJson(object):
''' Sanitizes requests so that only JSON is accepted. '''
def process_request(self, req, resp):
''' Executed before passing the request to the API. '''
if not req.client_accepts_json:
raise falcon.HTTPNotAcceptable(
'This API only supports responses encoded as JSON.')

View File

@ -0,0 +1,74 @@
import os
import sys
import alembic
import sqlalchemy
import logging.config
# make szurubooru module importable
dir_to_self = os.path.dirname(os.path.realpath(__file__))
sys.path.append(os.path.join(dir_to_self, *[os.pardir] * 2))
import szurubooru.model.base
import szurubooru.config
alembic_config = alembic.context.config
logging.config.fileConfig(alembic_config.config_file_name)
szuru_config = szurubooru.config.Config()
alembic_config.set_main_option(
'sqlalchemy.url',
'{schema}://{user}:{password}@{host}:{port}/{name}'.format(
schema=szuru_config['database']['schema'],
user=szuru_config['database']['user'],
password=szuru_config['database']['pass'],
host=szuru_config['database']['host'],
port=szuru_config['database']['port'],
name=szuru_config['database']['name']))
target_metadata = szurubooru.model.Base.metadata
def run_migrations_offline():
'''
Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
'''
url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure(
url=url, target_metadata=target_metadata, literal_binds=True)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
def run_migrations_online():
'''
Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
'''
connectable = sqlalchemy.engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section),
prefix='sqlalchemy.',
poolclass=sqlalchemy.pool.NullPool)
with connectable.connect() as connection:
alembic.context.configure(
connection=connection,
target_metadata=target_metadata)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
if alembic.context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,21 @@
'''
${message}
Revision ID: ${up_revision}
Created at: ${create_date}
'''
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,25 @@
'''
Make login time nullable
Revision ID: 7032abdf6efd
Created at: 2016-03-28 13:35:59.147167
'''
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = '7032abdf6efd'
down_revision = '89ca368219b6'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
'user', 'last_login_time',
existing_type=postgresql.TIMESTAMP(), nullable=True)
def downgrade():
op.alter_column(
'user', 'last_login_time',
existing_type=postgresql.TIMESTAMP(), nullable=False)

View File

@ -0,0 +1,22 @@
'''
Changes access rank column to string
Revision ID: 89ca368219b6
Created at: 2016-03-28 10:35:40.285485
'''
import sqlalchemy as sa
from alembic import op
revision = '89ca368219b6'
down_revision = 'd186d2e9c2c9'
branch_labels = None
depends_on = None
def upgrade():
op.drop_column('user', 'access_rank')
op.add_column('user', sa.Column('access_rank', sa.String(length=32), nullable=False))
def downgrade():
op.drop_column('user', 'access_rank')
op.add_column('user', sa.Column('access_rank', sa.INTEGER(), autoincrement=False, nullable=False))

View File

@ -0,0 +1,20 @@
'''
Add unique constraint to the user name
Revision ID: d186d2e9c2c9
Created at: 2016-03-28 10:21:30.440333
'''
import sqlalchemy as sa
from alembic import op
revision = 'd186d2e9c2c9'
down_revision = 'e5c1216a8503'
branch_labels = None
depends_on = None
def upgrade():
op.create_unique_constraint('uq_user_name', 'user', ['name'])
def downgrade():
op.drop_constraint('uq_user_name', 'user', type_='unique')

View File

@ -0,0 +1,31 @@
'''
Create user table
Revision ID: e5c1216a8503
Created at: 2016-03-20 15:53:25.030415
'''
import sqlalchemy as sa
from alembic import op
revision = 'e5c1216a8503'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('password_hash', sa.String(length=64), nullable=False),
sa.Column('pasword_salt', sa.String(length=32), nullable=True),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('access_rank', sa.Integer(), nullable=False),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_login_time', sa.DateTime(), nullable=False),
sa.Column('avatar_style', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id'))
def downgrade():
op.drop_table('user')

View File

@ -0,0 +1,6 @@
'''
Database models.
'''
from szurubooru.model.base import Base
from szurubooru.model.user import User

View File

@ -0,0 +1,4 @@
''' Base model for every database resource. '''
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base() # pylint: disable=invalid-name

View File

@ -0,0 +1,23 @@
# pylint: disable=too-many-instance-attributes,too-few-public-methods
''' Exports User. '''
import sqlalchemy as sa
from szurubooru.model.base import Base
class User(Base):
''' Database representation of an user. '''
__tablename__ = 'user'
AVATAR_GRAVATAR = 1
AVATAR_MANUAL = 2
user_id = sa.Column('id', sa.Integer, primary_key=True)
name = sa.Column('name', sa.String(50), nullable=False, unique=True)
password_hash = sa.Column('password_hash', sa.String(64), nullable=False)
password_salt = sa.Column('pasword_salt', sa.String(32))
email = sa.Column('email', sa.String(200), nullable=True)
access_rank = sa.Column('access_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.Integer, nullable=False)

View File

@ -0,0 +1,9 @@
'''
Middle layer between REST API and database.
All the business logic goes here.
'''
from szurubooru.services.auth_service import AuthService, AuthError
from szurubooru.services.user_service import UserService
from szurubooru.services.password_service import PasswordService
from szurubooru.services.errors import AuthError, IntegrityError

View File

@ -0,0 +1,32 @@
''' Exports AuthService. '''
from szurubooru.services.errors import AuthError
class AuthService(object):
''' Services related to user authentication '''
def __init__(self, config, password_service):
self._config = config
self._password_service = password_service
def is_valid_password(self, user, password):
''' Returns whether the given password for a given user is valid. '''
salt, valid_hash = user.password_salt, user.password_hash
possible_hashes = [
self._password_service.get_password_hash(salt, password),
self._password_service.get_legacy_password_hash(salt, password)
]
return valid_hash in possible_hashes
def verify_privilege(self, user, privilege_name):
'''
Throws an AuthError if the given user doesn't have given privilege.
'''
all_ranks = self._config['service']['user_ranks']
assert privilege_name in self._config['privileges']
assert user.access_rank in all_ranks
minimal_rank = self._config['privileges'][privilege_name]
good_ranks = all_ranks[all_ranks.index(minimal_rank):]
if user.access_rank not in good_ranks:
raise AuthError('Insufficient privileges to do this.')

View File

@ -0,0 +1,9 @@
''' Exports custom errors. '''
class AuthError(RuntimeError):
''' Generic authentication error '''
pass
class IntegrityError(RuntimeError):
''' Database integrity error '''
pass

View File

@ -0,0 +1,36 @@
''' Exports PasswordService. '''
import hashlib
import random
class PasswordService(object):
''' Stateless utilities for passwords '''
def __init__(self, config):
self._config = config
def get_password_hash(self, salt, password):
''' Retrieves new-style password hash. '''
digest = hashlib.sha256()
digest.update(self._config['basic']['secret'].encode('utf8'))
digest.update(salt.encode('utf8'))
digest.update(password.encode('utf8'))
return digest.hexdigest()
def get_legacy_password_hash(self, salt, password):
''' Retrieves old-style password hash. '''
digest = hashlib.sha1()
digest.update(b'1A2/$_4xVa')
digest.update(salt.encode('utf8'))
digest.update(password.encode('utf8'))
return digest.hexdigest()
def create_password(self):
''' Creates an easy-to-remember password. '''
alphabet = {
'c': list('bcdfghijklmnpqrstvwxyz'),
'v': list('aeiou'),
'n': list('0123456789'),
}
pattern = 'cvcvnncvcv'
return ''.join(random.choice(alphabet[l]) for l in list(pattern))

View File

@ -0,0 +1,31 @@
''' Exports UserService. '''
from datetime import datetime
from szurubooru.model.user import User
class UserService(object):
''' User management '''
def __init__(self, config, password_service):
self._config = config
self._password_service = password_service
def create_user(self, session, name, password, email):
''' Creates an user with given parameters and returns it. '''
user = User()
user.name = name
user.password = password
user.password_salt = self._password_service.create_password()
user.password_hash = self._password_service.get_password_hash(
user.password_salt, user.password)
user.email = email
user.access_rank = self._config['service']['default_user_rank']
user.creation_time = datetime.now()
user.avatar_style = User.AVATAR_GRAVATAR
session.add(user)
return user
def get_by_name(self, session, name):
''' Retrieves an user by its name. '''
return session.query(User).filter_by(name=name).first()

View File

@ -0,0 +1,8 @@
''' Exports dotdict. '''
class dotdict(dict): # pylint: disable=invalid-name
'''dot.notation access to dictionary attributes'''
def __getattr__(self, attr):
return self.get(attr)
__setattr__ = dict.__setitem__
__delattr__ = dict.__delitem__