summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorrr- <rr-@sakuya.pl>2016-04-01 18:45:25 +0200
committerrr- <rr-@sakuya.pl>2016-04-01 18:48:16 +0200
commite487adcc97b8d2bd111a34b7d3c72d66001deb8b (patch)
treed684abf99bf176ead0a785bc237ae3b6a81417c1 /server
parent1ad71585c4e95555566e8af068d5addf75b33274 (diff)
split files into client/ and server/
Diffstat (limited to 'server')
-rw-r--r--server/alembic.ini39
-rwxr-xr-xserver/host-waitress26
-rw-r--r--server/requirements.txt5
-rw-r--r--server/szurubooru/__init__.py0
-rw-r--r--server/szurubooru/api/__init__.py3
-rw-r--r--server/szurubooru/api/users.py81
-rw-r--r--server/szurubooru/app.py62
-rw-r--r--server/szurubooru/config.py38
-rw-r--r--server/szurubooru/middleware/__init__.py6
-rw-r--r--server/szurubooru/middleware/authenticator.py60
-rw-r--r--server/szurubooru/middleware/db_session.py18
-rw-r--r--server/szurubooru/middleware/json_translator.py44
-rw-r--r--server/szurubooru/middleware/require_json.py12
-rw-r--r--server/szurubooru/migrations/env.py74
-rw-r--r--server/szurubooru/migrations/script.py.mako21
-rw-r--r--server/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py25
-rw-r--r--server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py22
-rw-r--r--server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py20
-rw-r--r--server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py31
-rw-r--r--server/szurubooru/model/__init__.py6
-rw-r--r--server/szurubooru/model/base.py4
-rw-r--r--server/szurubooru/model/user.py23
-rw-r--r--server/szurubooru/services/__init__.py9
-rw-r--r--server/szurubooru/services/auth_service.py32
-rw-r--r--server/szurubooru/services/errors.py9
-rw-r--r--server/szurubooru/services/password_service.py36
-rw-r--r--server/szurubooru/services/user_service.py31
-rw-r--r--server/szurubooru/util.py8
28 files changed, 745 insertions, 0 deletions
diff --git a/server/alembic.ini b/server/alembic.ini
new file mode 100644
index 0000000..a49947c
--- /dev/null
+++ b/server/alembic.ini
@@ -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
diff --git a/server/host-waitress b/server/host-waitress
new file mode 100755
index 0000000..c179858
--- /dev/null
+++ b/server/host-waitress
@@ -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()
diff --git a/server/requirements.txt b/server/requirements.txt
new file mode 100644
index 0000000..0122fa6
--- /dev/null
+++ b/server/requirements.txt
@@ -0,0 +1,5 @@
+alembic>=0.8.5
+configobj>=5.0.6
+falcon>=0.3.0
+psycopg2>=2.6.1
+SQLAlchemy>=1.0.12
diff --git a/server/szurubooru/__init__.py b/server/szurubooru/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/szurubooru/__init__.py
diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py
new file mode 100644
index 0000000..7075658
--- /dev/null
+++ b/server/szurubooru/api/__init__.py
@@ -0,0 +1,3 @@
+''' Falcon-compatible API facades. '''
+
+from szurubooru.api.users import UserListApi, UserDetailApi
diff --git a/server/szurubooru/api/users.py b/server/szurubooru/api/users.py
new file mode 100644
index 0000000..ead769b
--- /dev/null
+++ b/server/szurubooru/api/users.py
@@ -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}
diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py
new file mode 100644
index 0000000..5868aca
--- /dev/null
+++ b/server/szurubooru/app.py
@@ -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
diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py
new file mode 100644
index 0000000..4b2827e
--- /dev/null
+++ b/server/szurubooru/config.py
@@ -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']))
diff --git a/server/szurubooru/middleware/__init__.py b/server/szurubooru/middleware/__init__.py
new file mode 100644
index 0000000..a2ba328
--- /dev/null
+++ b/server/szurubooru/middleware/__init__.py
@@ -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
diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py
new file mode 100644
index 0000000..df96cdb
--- /dev/null
+++ b/server/szurubooru/middleware/authenticator.py
@@ -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
diff --git a/server/szurubooru/middleware/db_session.py b/server/szurubooru/middleware/db_session.py
new file mode 100644
index 0000000..dff317e
--- /dev/null
+++ b/server/szurubooru/middleware/db_session.py
@@ -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()
diff --git a/server/szurubooru/middleware/json_translator.py b/server/szurubooru/middleware/json_translator.py
new file mode 100644
index 0000000..f04e6c8
--- /dev/null
+++ b/server/szurubooru/middleware/json_translator.py
@@ -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)
diff --git a/server/szurubooru/middleware/require_json.py b/server/szurubooru/middleware/require_json.py
new file mode 100644
index 0000000..c593426
--- /dev/null
+++ b/server/szurubooru/middleware/require_json.py
@@ -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.')
diff --git a/server/szurubooru/migrations/env.py b/server/szurubooru/migrations/env.py
new file mode 100644
index 0000000..c10f0a1
--- /dev/null
+++ b/server/szurubooru/migrations/env.py
@@ -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()
diff --git a/server/szurubooru/migrations/script.py.mako b/server/szurubooru/migrations/script.py.mako
new file mode 100644
index 0000000..13adc51
--- /dev/null
+++ b/server/szurubooru/migrations/script.py.mako
@@ -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"}
diff --git a/server/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py b/server/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py
new file mode 100644
index 0000000..9d6b4b9
--- /dev/null
+++ b/server/szurubooru/migrations/versions/7032abdf6efd_make_login_time_nullable.py
@@ -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)
diff --git a/server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py b/server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py
new file mode 100644
index 0000000..c9252dc
--- /dev/null
+++ b/server/szurubooru/migrations/versions/89ca368219b6_change_user_access_rank.py
@@ -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))
diff --git a/server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py b/server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py
new file mode 100644
index 0000000..a04f019
--- /dev/null
+++ b/server/szurubooru/migrations/versions/d186d2e9c2c9_add_unique_constraint_to_user_name.py
@@ -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')
diff --git a/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py b/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py
new file mode 100644
index 0000000..0826448
--- /dev/null
+++ b/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py
@@ -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')
diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py
new file mode 100644
index 0000000..8423e0d
--- /dev/null
+++ b/server/szurubooru/model/__init__.py
@@ -0,0 +1,6 @@
+'''
+Database models.
+'''
+
+from szurubooru.model.base import Base
+from szurubooru.model.user import User
diff --git a/server/szurubooru/model/base.py b/server/szurubooru/model/base.py
new file mode 100644
index 0000000..d622ddf
--- /dev/null
+++ b/server/szurubooru/model/base.py
@@ -0,0 +1,4 @@
+''' Base model for every database resource. '''
+
+from sqlalchemy.ext.declarative import declarative_base
+Base = declarative_base() # pylint: disable=invalid-name
diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py
new file mode 100644
index 0000000..f611a52
--- /dev/null
+++ b/server/szurubooru/model/user.py
@@ -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)
diff --git a/server/szurubooru/services/__init__.py b/server/szurubooru/services/__init__.py
new file mode 100644
index 0000000..efd5e5f
--- /dev/null
+++ b/server/szurubooru/services/__init__.py
@@ -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
diff --git a/server/szurubooru/services/auth_service.py b/server/szurubooru/services/auth_service.py
new file mode 100644
index 0000000..638ce6f
--- /dev/null
+++ b/server/szurubooru/services/auth_service.py
@@ -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.')
diff --git a/server/szurubooru/services/errors.py b/server/szurubooru/services/errors.py
new file mode 100644
index 0000000..09fbe51
--- /dev/null
+++ b/server/szurubooru/services/errors.py
@@ -0,0 +1,9 @@
+''' Exports custom errors. '''
+
+class AuthError(RuntimeError):
+ ''' Generic authentication error '''
+ pass
+
+class IntegrityError(RuntimeError):
+ ''' Database integrity error '''
+ pass
diff --git a/server/szurubooru/services/password_service.py b/server/szurubooru/services/password_service.py
new file mode 100644
index 0000000..d330dd9
--- /dev/null
+++ b/server/szurubooru/services/password_service.py
@@ -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))
diff --git a/server/szurubooru/services/user_service.py b/server/szurubooru/services/user_service.py
new file mode 100644
index 0000000..d8ce8b6
--- /dev/null
+++ b/server/szurubooru/services/user_service.py
@@ -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()
diff --git a/server/szurubooru/util.py b/server/szurubooru/util.py
new file mode 100644
index 0000000..929957d
--- /dev/null
+++ b/server/szurubooru/util.py
@@ -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__