From 55cc7b59e4a9c23810371fa3fa3895ec8c3a7bf8 Mon Sep 17 00:00:00 2001 From: rr- Date: Wed, 6 Apr 2016 20:38:45 +0200 Subject: [PATCH] client+server: switch to yaml config --- .gitignore | 2 +- INSTALL.md | 19 ++-- client/build.js | 37 ++++--- client/js/api.js | 6 +- client/js/views/help_view.js | 2 +- client/js/views/home_view.js | 2 +- client/js/views/login_view.js | 4 +- client/js/views/registration_view.js | 4 +- client/package.json | 4 +- config.ini.dist | 90 ---------------- config.yaml.dist | 102 ++++++++++++++++++ server/requirements.txt | 2 +- server/szurubooru/api/password_reset_api.py | 8 +- server/szurubooru/config.py | 44 +++++--- .../tests/api/test_password_reset_api.py | 8 +- server/szurubooru/tests/api/test_user_api.py | 30 ++---- server/szurubooru/util/auth.py | 6 +- server/szurubooru/util/users.py | 10 +- 18 files changed, 201 insertions(+), 179 deletions(-) delete mode 100644 config.ini.dist create mode 100644 config.yaml.dist diff --git a/.gitignore b/.gitignore index 378345b..e2548a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -config.ini +config.yaml */*_modules/ diff --git a/INSTALL.md b/INSTALL.md index b96680f..04b5cc9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -75,12 +75,12 @@ user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox 1. Configure things: ```console - user@host:szuru$ cp config.ini.dist config.ini - user@host:szuru$ vim config.ini + user@host:szuru$ cp config.yaml.dist config.yaml + user@host:szuru$ vim config.yaml ``` - Pay extra attention to the `[database]` section, `[smtp]` section, API URL - and base URL in `[basic]`. + Pay extra attention to API URL, base URL, the `database` section and the + `smtp` section. 2. Compile the frontend: @@ -132,7 +132,7 @@ Below are described the methods to integrate the API into a web server: `uwsgi`, but they'll need to write wrapper scripts themselves. Note that the API URL in the virtual host configuration needs to be the same as -the one in the `config.ini`, so that client knows how to access the backend! +the one in the `config.yaml`, so that client knows how to access the backend! #### Example @@ -157,12 +157,11 @@ server { } ``` -**`config.ini`**: +**`config.yaml`**: -```ini -[basic] -api_url = http://big.dude/api/ -base_url = http://big.dude/ +```yaml +api_url: 'http://big.dude/api/' +base_url: 'http://big.dude/' ``` Then the backend is started with `./server/host-waitress` from within diff --git a/client/build.js b/client/build.js index eb32e9b..70e63e5 100644 --- a/client/build.js +++ b/client/build.js @@ -5,40 +5,47 @@ const glob = require('glob'); const path = require('path'); const util = require('util'); const execSync = require('child_process').execSync; +const camelcase = require('camelcase'); + +function convertKeysToCamelCase(input) { + let result = {}; + Object.keys(input).map((key, _) => { + const value = input[key]; + if (value !== null && value.constructor == Object) { + result[camelcase(key)] = convertKeysToCamelCase(value); + } else { + result[camelcase(key)] = value; + } + }); + return result; +} function getVersion() { return execSync('git describe --always --dirty --long --tags').toString(); } function getConfig() { - const ini = require('ini'); + const yaml = require('js-yaml'); const merge = require('merge'); const camelcaseKeys = require('camelcase-keys'); - function parseIniFile(path) { - let result = ini.parse(fs.readFileSync(path, 'utf-8') - .replace(/#.+$/gm, '') - .replace(/\s+$/gm, '')); - Object.keys(result).map((key, _) => { - result[key] = camelcaseKeys(result[key]); - }); - return result; + function parseConfigFile(path) { + let result = yaml.load(fs.readFileSync(path, 'utf-8')); + return convertKeysToCamelCase(result); } - let config = parseIniFile('../config.ini.dist'); + let config = parseConfigFile('../config.yaml.dist'); try { - const localConfig = parseIniFile('../config.ini'); + const localConfig = parseConfigFile('../config.yaml'); config = merge.recursive(config, localConfig); } catch (e) { console.warn('Local config does not exist, ignoring'); } - delete config.basic.secret; + delete config.secret; delete config.smtp; delete config.database; - config.service.userRanks = config.service.userRanks.split(/,\s*/); - config.service.tagCategories = config.service.tagCategories.split(/,\s*/); config.meta = { version: getVersion(), buildDate: new Date().toUTCString(), @@ -63,7 +70,7 @@ function bundleHtml(config) { .replace(/(<\/head>)/, templatesHtml + '$1') .replace( /()(.*)(<\/title>)/, - util.format('$1%s$3', config.basic.name)); + util.format('$1%s$3', config.name)); fs.writeFileSync( './public/index.htm', diff --git a/client/js/api.js b/client/js/api.js index a881b56..497525e 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -48,7 +48,7 @@ class Api { continue; } const rankName = config.privileges[privilege]; - const rankIndex = config.service.userRanks.indexOf(rankName); + const rankIndex = config.ranks.indexOf(rankName); if (minViableRank === null || rankIndex < minViableRank) { minViableRank = rankIndex; } @@ -57,7 +57,7 @@ class Api { console.error('Bad privilege name: ' + lookup); } let myRank = this.user !== null ? - config.service.userRanks.indexOf(this.user.accessRank) : + config.ranks.indexOf(this.user.rank) : 0; return myRank >= minViableRank; } @@ -91,7 +91,7 @@ class Api { } getFullUrl(url) { - return (config.basic.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/'); + return (config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/'); } } diff --git a/client/js/views/help_view.js b/client/js/views/help_view.js index 4f68a7d..eaf6c14 100644 --- a/client/js/views/help_view.js +++ b/client/js/views/help_view.js @@ -25,7 +25,7 @@ class HelpView extends BaseView { } const content = this.sectionTemplates[section]({ - 'name': config.basic.name, + 'name': config.name, }); this.showView(this.template({'content': content})); diff --git a/client/js/views/home_view.js b/client/js/views/home_view.js index 1f1f0f4..9a8603d 100644 --- a/client/js/views/home_view.js +++ b/client/js/views/home_view.js @@ -11,7 +11,7 @@ class HomeView extends BaseView { render(section) { this.showView(this.template({ - name: config.basic.name, + name: config.name, version: config.meta.version, buildDate: config.meta.buildDate, })); diff --git a/client/js/views/login_view.js b/client/js/views/login_view.js index 2f24053..3708e65 100644 --- a/client/js/views/login_view.js +++ b/client/js/views/login_view.js @@ -17,8 +17,8 @@ class LoginView extends BaseView { const userNameField = document.getElementById('user-name'); const passwordField = document.getElementById('user-password'); const rememberUserField = document.getElementById('remember-user'); - userNameField.setAttribute('pattern', config.service.userNameRegex); - passwordField.setAttribute('pattern', config.service.passwordRegex); + userNameField.setAttribute('pattern', config.userNameRegex); + passwordField.setAttribute('pattern', config.passwordRegex); form.addEventListener('submit', e => { e.preventDefault(); diff --git a/client/js/views/registration_view.js b/client/js/views/registration_view.js index 7e1ee85..1a1583e 100644 --- a/client/js/views/registration_view.js +++ b/client/js/views/registration_view.js @@ -17,8 +17,8 @@ class RegistrationView extends BaseView { const userNameField = document.getElementById('user-name'); const passwordField = document.getElementById('user-password'); const emailField = document.getElementById('user-email'); - userNameField.setAttribute('pattern', config.service.userNameRegex); - passwordField.setAttribute('pattern', config.service.passwordRegex); + userNameField.setAttribute('pattern', config.userNameRegex); + passwordField.setAttribute('pattern', config.passwordRegex); form.addEventListener('submit', e => { e.preventDefault(); diff --git a/client/package.json b/client/package.json index da59222..8f8fca2 100644 --- a/client/package.json +++ b/client/package.json @@ -7,13 +7,13 @@ }, "dependencies": { "browserify": "^13.0.0", - "camelcase-keys": "^2.1.0", + "camelcase": "^2.1.1", "csso": "^1.8.0", "glob": "^7.0.3", "handlebars": "^4.0.5", "html-minifier": "^1.3.1", - "ini": "^1.3.4", "js-cookie": "^2.1.0", + "js-yaml": "^3.5.5", "merge": "^1.2.0", "page": "^1.7.1", "superagent": "^1.8.3", diff --git a/config.ini.dist b/config.ini.dist deleted file mode 100644 index ba3d4d4..0000000 --- a/config.ini.dist +++ /dev/null @@ -1,90 +0,0 @@ -# rather than editing this file, it is strongly suggested to create config.ini -# and override only what you need. - -[basic] -name = szurubooru -debug = 0 -secret = change -api_url = "http://api.example.com/" # where frontend connects to -base_url = "http://example.com/" # used in absolute links (e.g. password reminder) - -[database] -schema = postgres -host = localhost -port = 5432 -user = szuru -pass = dog -name = szuru - -[smtp] -# used to send password reminders -host = localhost -port = 25 -user = bot -pass = groovy123 - -[service] -user_ranks = anonymous, regular_user, power_user, mod, admin, nobody -default_user_rank = regular_user -users_per_page = 20 -posts_per_page = 40 -max_comment_length = 5000 -tag_categories = meta, artist, character, copyright, other unique - -# don't change these regexes, unless you want to annoy people. but if you do -# customize them, make sure to update the instructions in the registration form -# template as well. -password_regex = "^.{5,}$" -user_name_regex = "^[a-zA-Z0-9_-]{1,32}$" - -[privileges] -users:create = anonymous -users:list = regular_user -users:view = regular_user -users:edit:any:name = mod -users:edit:any:pass = mod -users:edit:any:email = mod -users:edit:any:avatar = mod -# note: promoting people to higher rank than one's own is always impossible -users:edit:any:rank = mod -users:edit:self:name = regular_user -users:edit:self:pass = regular_user -users:edit:self:email = regular_user -users:edit:self:avatar = regular_user -users:edit:self:rank = mod -users:delete:any = admin -users:delete:self = regular_user - -posts:create:anonymous = regular_user -posts:create:identified = regular_user -posts:list = anonymous -posts:view = anonymous -posts:edit:content = power_user -posts:edit:flags = regular_user -posts:edit:notes = regular_user -posts:edit:relations = regular_user -posts:edit:safety = power_user -posts:edit:source = regular_user -posts:edit:tags = regular_user -posts:edit:thumbnail = power_user -posts:feature = mod -posts:delete = mod - -tags:create = regular_user -tags:edit:name = power_user -tags:edit:category = power_user -tags:edit:implications = power_user -tags:edit:suggestions = power_user -tags:list = regular_user -tags:masstag = power_user -tags:merge = mod -tags:delete = mod - -comments:create = regular_user -comments:delete:any = mod -comments:delete:own = regular_user -comments:edit:any = mod -comments:edit:own = regular_user -comments:list = regular_user - -history:view = power_user diff --git a/config.yaml.dist b/config.yaml.dist new file mode 100644 index 0000000..8e844b9 --- /dev/null +++ b/config.yaml.dist @@ -0,0 +1,102 @@ +# rather than editing this file, it is strongly suggested to create config.ini +# and override only what you need. + +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/ + +database: + schema: postgres + host: # example: localhost + port: # example: 5432 + user: # example: szuru + pass: # example: dog + name: # example: szuru + +# used to send password reminders +smtp: + host: # example: localhost + port: # example: 25 + user: # example: bot + pass: # example: groovy123 + +limits: + users_per_page: 20 + posts_per_page: 40 + max_comment_length: 5000 + +tag_categories: + - meta + - artist + - character + - copyright + - other unique + +# changing ranks after deployment may require manual tweaks to the database. +ranks: + - anonymous + - regular_user + - power_user + - mod + - admin + - nobody +default_rank: regular_user + +# don't change these, unless you want to annoy people. if you do customize +# them though, make sure to update the instructions in the registration form +# template as well. +password_regex: '^.{5,}$' +user_name_regex: '^[a-zA-Z0-9_-]{1,32}$' + +privileges: + 'users:create': anonymous + 'users:list': regular_user + 'users:view': regular_user + 'users:edit:any:name': mod + 'users:edit:any:pass': mod + 'users:edit:any:email': mod + 'users:edit:any:avatar': mod + 'users:edit:any:rank': mod + 'users:edit:self:name': regular_user + 'users:edit:self:pass': regular_user + 'users:edit:self:email': regular_user + 'users:edit:self:avatar': regular_user + 'users:edit:self:rank': mod # one can't promote themselves or anyone to upper rank than their own. + 'users:delete:any': admin + 'users:delete:self': regular_user + + 'posts:create:anonymous': regular_user + 'posts:create:identified': regular_user + 'posts:list': anonymous + 'posts:view': anonymous + 'posts:edit:content': power_user + 'posts:edit:flags': regular_user + 'posts:edit:notes': regular_user + 'posts:edit:relations': regular_user + 'posts:edit:safety': power_user + 'posts:edit:source': regular_user + 'posts:edit:tags': regular_user + 'posts:edit:thumbnail': power_user + 'posts:feature': mod + 'posts:delete': mod + + 'tags:create': regular_user + 'tags:edit:name': power_user + 'tags:edit:category': power_user + 'tags:edit:implications': power_user + 'tags:edit:suggestions': power_user + 'tags:list': regular_user + 'tags:masstag': power_user + 'tags:merge': mod + 'tags:delete': mod + + 'comments:create': regular_user + 'comments:delete:any': mod + 'comments:delete:own': regular_user + 'comments:edit:any': mod + 'comments:edit:own': regular_user + 'comments:list': regular_user + + 'history:view': power_user diff --git a/server/requirements.txt b/server/requirements.txt index 614cf9c..5c0d15a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,5 +1,5 @@ alembic>=0.8.5 -configobj>=5.0.6 +pyyaml>=3.11 falcon>=0.3.0 psycopg2>=2.6.1 SQLAlchemy>=1.0.12 diff --git a/server/szurubooru/api/password_reset_api.py b/server/szurubooru/api/password_reset_api.py index 45ef042..47c1f12 100644 --- a/server/szurubooru/api/password_reset_api.py +++ b/server/szurubooru/api/password_reset_api.py @@ -19,12 +19,12 @@ class PasswordResetApi(BaseApi): 'User %r hasn\'t supplied email. Cannot reset password.' % user_name) token = auth.generate_authentication_token(user) url = '%s/password-reset/%s:%s' % ( - config.config['basic']['base_url'].rstrip('/'), user.name, token) + config.config['base_url'].rstrip('/'), user.name, token) mailer.send_mail( - 'noreply@%s' % config.config['basic']['name'], + 'noreply@%s' % config.config['name'], user.email, - MAIL_SUBJECT.format(name=config.config['basic']['name']), - MAIL_BODY.format(name=config.config['basic']['name'], url=url)) + MAIL_SUBJECT.format(name=config.config['name']), + MAIL_BODY.format(name=config.config['name'], url=url)) return {} def post(self, context, user_name): diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py index ccb8b23..38637c3 100644 --- a/server/szurubooru/config.py +++ b/server/szurubooru/config.py @@ -1,13 +1,26 @@ import os -import configobj +import yaml from szurubooru import errors +def merge(left, right): + for key in right: + if key in left: + if isinstance(left[key], dict) and isinstance(right[key], dict): + merge(left[key], right[key]) + elif left[key] != right[key]: + left[key] = right[key] + else: + left[key] = right[key] + return left + class Config(object): - ''' INI config parser and container. ''' + ''' 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')) + with open('../config.yaml.dist') as handle: + self.config = yaml.load(handle.read()) + if os.path.exists('../config.yaml'): + with open('../config.yaml') as handle: + self.config = merge(self.config, yaml.load(handle.read())) self._validate() def __getitem__(self, key): @@ -15,22 +28,25 @@ class Config(object): def _validate(self): ''' - Check whether config.ini doesn't contain errors that might prove + Check whether config doesn't contain errors that might prove lethal at runtime. ''' - all_ranks = self['service']['user_ranks'] + all_ranks = self['ranks'] for privilege, rank in self['privileges'].items(): if rank not in all_ranks: raise errors.ConfigError( - 'Rank %r for privilege %r is missing from user_ranks' % ( - rank, privilege)) + 'Rank %r for privilege %r is missing' % (rank, privilege)) for rank in ['anonymous', 'admin', 'nobody']: if rank not in all_ranks: - raise errors.ConfigError( - 'Fixed rank %r is missing from user_ranks' % rank) - if self['service']['default_user_rank'] not in all_ranks: + raise errors.ConfigError('Protected rank %r is missing' % rank) + if self['default_rank'] not in all_ranks: raise errors.ConfigError( - 'Default rank %r is missing from user_ranks' % ( - self['service']['default_user_rank'])) + 'Default rank %r is not on the list of known ranks' % ( + self['default_rank'])) + + for key in ['schema', 'host', 'port', 'user', 'pass', 'name']: + if not self['database'][key]: + raise errors.ConfigError( + 'Database is not configured: %r is missing' % key) config = Config() # pylint: disable=invalid-name diff --git a/server/szurubooru/tests/api/test_password_reset_api.py b/server/szurubooru/tests/api/test_password_reset_api.py index 1e7a43d..60f766d 100644 --- a/server/szurubooru/tests/api/test_password_reset_api.py +++ b/server/szurubooru/tests/api/test_password_reset_api.py @@ -9,11 +9,9 @@ class TestPasswordReset(DatabaseTestCase): def setUp(self): super().setUp() config_mock = { - 'basic': { - 'secret': 'x', - 'base_url': 'http://example.com/', - 'name': 'Test instance', - }, + 'secret': 'x', + 'base_url': 'http://example.com/', + 'name': 'Test instance', } self.old_config = config.config config.config = config_mock diff --git a/server/szurubooru/tests/api/test_user_api.py b/server/szurubooru/tests/api/test_user_api.py index b2b4b8c..9ea962a 100644 --- a/server/szurubooru/tests/api/test_user_api.py +++ b/server/szurubooru/tests/api/test_user_api.py @@ -13,9 +13,7 @@ class TestRetrievingUsers(DatabaseTestCase): 'users:view': 'regular_user', 'users:create': 'regular_user', }, - 'service': { - 'user_ranks': ['anonymous', 'regular_user', 'mod', 'admin'], - }, + 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], } self.old_config = config.config config.config = config_mock @@ -74,15 +72,11 @@ class TestCreatingUser(DatabaseTestCase): def setUp(self): super().setUp() config_mock = { - 'basic': { - 'secret': '', - }, - 'service': { - 'user_name_regex': '.{3,}', - 'password_regex': '.{3,}', - 'user_ranks': ['anonymous', 'regular_user', 'mod', 'admin'], - 'default_user_rank': 'regular_user', - }, + 'secret': '', + 'user_name_regex': '.{3,}', + 'password_regex': '.{3,}', + 'default_rank': 'regular_user', + 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'privileges': { 'users:create': 'anonymous', }, @@ -146,14 +140,10 @@ class TestUpdatingUser(DatabaseTestCase): def setUp(self): super().setUp() config_mock = { - 'basic': { - 'secret': '', - }, - 'service': { - 'user_name_regex': '.{3,}', - 'password_regex': '.{3,}', - 'user_ranks': ['anonymous', 'regular_user', 'mod', 'admin'], - }, + 'secret': '', + 'user_name_regex': '.{3,}', + 'password_regex': '.{3,}', + 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], 'privileges': { 'users:edit:self:name': 'regular_user', 'users:edit:self:pass': 'regular_user', diff --git a/server/szurubooru/util/auth.py b/server/szurubooru/util/auth.py index 2f65b39..bb7223f 100644 --- a/server/szurubooru/util/auth.py +++ b/server/szurubooru/util/auth.py @@ -6,7 +6,7 @@ from szurubooru import errors def get_password_hash(salt, password): ''' Retrieve new-style password hash. ''' digest = hashlib.sha256() - digest.update(config.config['basic']['secret'].encode('utf8')) + digest.update(config.config['secret'].encode('utf8')) digest.update(salt.encode('utf8')) digest.update(password.encode('utf8')) return digest.hexdigest() @@ -42,7 +42,7 @@ def verify_privilege(user, privilege_name): ''' Throw an AuthError if the given user doesn't have given privilege. ''' - all_ranks = config.config['service']['user_ranks'] + all_ranks = config.config['ranks'] assert privilege_name in config.config['privileges'] assert user.rank in all_ranks @@ -54,6 +54,6 @@ def verify_privilege(user, privilege_name): def generate_authentication_token(user): ''' Generate nonguessable challenge (e.g. links in password reminder). ''' digest = hashlib.md5() - digest.update(config.config['basic']['secret'].encode('utf8')) + digest.update(config.config['secret'].encode('utf8')) digest.update(user.password_salt.encode('utf8')) return digest.hexdigest() diff --git a/server/szurubooru/util/users.py b/server/szurubooru/util/users.py index 4e68ec6..f1fb654 100644 --- a/server/szurubooru/util/users.py +++ b/server/szurubooru/util/users.py @@ -10,7 +10,7 @@ def create_user(name, password, email): update_name(user, name) update_password(user, password) update_email(user, email) - user.rank = config.config['service']['default_user_rank'] + user.rank = config.config['default_rank'] user.creation_time = datetime.now() user.avatar_style = db.User.AVATAR_GRAVATAR return user @@ -18,7 +18,7 @@ def create_user(name, password, email): def update_name(user, name): ''' Validate and update user's name. ''' name = name.strip() - name_regex = config.config['service']['user_name_regex'] + name_regex = config.config['user_name_regex'] if not re.match(name_regex, name): raise errors.ValidationError( 'Name must satisfy regex %r.' % name_regex) @@ -26,7 +26,7 @@ def update_name(user, name): def update_password(user, password): ''' Validate and update user's password. ''' - password_regex = config.config['service']['password_regex'] + password_regex = config.config['password_regex'] if not re.match(password_regex, password): raise errors.ValidationError( 'Password must satisfy regex %r.' % password_regex) @@ -43,10 +43,10 @@ def update_email(user, email): def update_rank(user, rank, authenticated_user): rank = rank.strip() - available_ranks = config.config['service']['user_ranks'] + available_ranks = config.config['ranks'] if not rank in available_ranks: raise errors.ValidationError( - 'Bad rank. Valid ranks: %r' % available_ranks) + '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')