mirror of
https://github.com/rr-/szurubooru.git
synced 2025-07-17 08:26:24 +00:00
split files into client/ and server/
This commit is contained in:
39
server/alembic.ini
Normal file
39
server/alembic.ini
Normal 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
26
server/host-waitress
Executable 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
5
server/requirements.txt
Normal 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
|
0
server/szurubooru/__init__.py
Normal file
0
server/szurubooru/__init__.py
Normal file
3
server/szurubooru/api/__init__.py
Normal file
3
server/szurubooru/api/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
''' Falcon-compatible API facades. '''
|
||||
|
||||
from szurubooru.api.users import UserListApi, UserDetailApi
|
81
server/szurubooru/api/users.py
Normal file
81
server/szurubooru/api/users.py
Normal 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
62
server/szurubooru/app.py
Normal 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
|
38
server/szurubooru/config.py
Normal file
38
server/szurubooru/config.py
Normal 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']))
|
6
server/szurubooru/middleware/__init__.py
Normal file
6
server/szurubooru/middleware/__init__.py
Normal 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
|
60
server/szurubooru/middleware/authenticator.py
Normal file
60
server/szurubooru/middleware/authenticator.py
Normal 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
|
18
server/szurubooru/middleware/db_session.py
Normal file
18
server/szurubooru/middleware/db_session.py
Normal 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()
|
44
server/szurubooru/middleware/json_translator.py
Normal file
44
server/szurubooru/middleware/json_translator.py
Normal 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)
|
12
server/szurubooru/middleware/require_json.py
Normal file
12
server/szurubooru/middleware/require_json.py
Normal 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.')
|
74
server/szurubooru/migrations/env.py
Normal file
74
server/szurubooru/migrations/env.py
Normal 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()
|
21
server/szurubooru/migrations/script.py.mako
Normal file
21
server/szurubooru/migrations/script.py.mako
Normal 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"}
|
@ -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)
|
@ -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))
|
@ -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')
|
@ -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')
|
6
server/szurubooru/model/__init__.py
Normal file
6
server/szurubooru/model/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
'''
|
||||
Database models.
|
||||
'''
|
||||
|
||||
from szurubooru.model.base import Base
|
||||
from szurubooru.model.user import User
|
4
server/szurubooru/model/base.py
Normal file
4
server/szurubooru/model/base.py
Normal 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
|
23
server/szurubooru/model/user.py
Normal file
23
server/szurubooru/model/user.py
Normal 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)
|
9
server/szurubooru/services/__init__.py
Normal file
9
server/szurubooru/services/__init__.py
Normal 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
|
32
server/szurubooru/services/auth_service.py
Normal file
32
server/szurubooru/services/auth_service.py
Normal 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.')
|
9
server/szurubooru/services/errors.py
Normal file
9
server/szurubooru/services/errors.py
Normal file
@ -0,0 +1,9 @@
|
||||
''' Exports custom errors. '''
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
''' Generic authentication error '''
|
||||
pass
|
||||
|
||||
class IntegrityError(RuntimeError):
|
||||
''' Database integrity error '''
|
||||
pass
|
36
server/szurubooru/services/password_service.py
Normal file
36
server/szurubooru/services/password_service.py
Normal 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))
|
31
server/szurubooru/services/user_service.py
Normal file
31
server/szurubooru/services/user_service.py
Normal 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()
|
8
server/szurubooru/util.py
Normal file
8
server/szurubooru/util.py
Normal 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__
|
Reference in New Issue
Block a user