client+server: switch to yaml config

This commit is contained in:
rr- 2016-04-06 20:38:45 +02:00
parent 19a357611b
commit 55cc7b59e4
18 changed files with 201 additions and 179 deletions

2
.gitignore vendored
View file

@ -1,2 +1,2 @@
config.ini config.yaml
*/*_modules/ */*_modules/

View file

@ -75,12 +75,12 @@ user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox
1. Configure things: 1. Configure things:
```console ```console
user@host:szuru$ cp config.ini.dist config.ini user@host:szuru$ cp config.yaml.dist config.yaml
user@host:szuru$ vim config.ini user@host:szuru$ vim config.yaml
``` ```
Pay extra attention to the `[database]` section, `[smtp]` section, API URL Pay extra attention to API URL, base URL, the `database` section and the
and base URL in `[basic]`. `smtp` section.
2. Compile the frontend: 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. `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 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 #### Example
@ -157,12 +157,11 @@ server {
} }
``` ```
**`config.ini`**: **`config.yaml`**:
```ini ```yaml
[basic] api_url: 'http://big.dude/api/'
api_url = http://big.dude/api/ base_url: 'http://big.dude/'
base_url = http://big.dude/
``` ```
Then the backend is started with `./server/host-waitress` from within Then the backend is started with `./server/host-waitress` from within

View file

@ -5,40 +5,47 @@ const glob = require('glob');
const path = require('path'); const path = require('path');
const util = require('util'); const util = require('util');
const execSync = require('child_process').execSync; 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() { function getVersion() {
return execSync('git describe --always --dirty --long --tags').toString(); return execSync('git describe --always --dirty --long --tags').toString();
} }
function getConfig() { function getConfig() {
const ini = require('ini'); const yaml = require('js-yaml');
const merge = require('merge'); const merge = require('merge');
const camelcaseKeys = require('camelcase-keys'); const camelcaseKeys = require('camelcase-keys');
function parseIniFile(path) { function parseConfigFile(path) {
let result = ini.parse(fs.readFileSync(path, 'utf-8') let result = yaml.load(fs.readFileSync(path, 'utf-8'));
.replace(/#.+$/gm, '') return convertKeysToCamelCase(result);
.replace(/\s+$/gm, ''));
Object.keys(result).map((key, _) => {
result[key] = camelcaseKeys(result[key]);
});
return result;
} }
let config = parseIniFile('../config.ini.dist'); let config = parseConfigFile('../config.yaml.dist');
try { try {
const localConfig = parseIniFile('../config.ini'); const localConfig = parseConfigFile('../config.yaml');
config = merge.recursive(config, localConfig); config = merge.recursive(config, localConfig);
} catch (e) { } catch (e) {
console.warn('Local config does not exist, ignoring'); console.warn('Local config does not exist, ignoring');
} }
delete config.basic.secret; delete config.secret;
delete config.smtp; delete config.smtp;
delete config.database; delete config.database;
config.service.userRanks = config.service.userRanks.split(/,\s*/);
config.service.tagCategories = config.service.tagCategories.split(/,\s*/);
config.meta = { config.meta = {
version: getVersion(), version: getVersion(),
buildDate: new Date().toUTCString(), buildDate: new Date().toUTCString(),
@ -63,7 +70,7 @@ function bundleHtml(config) {
.replace(/(<\/head>)/, templatesHtml + '$1') .replace(/(<\/head>)/, templatesHtml + '$1')
.replace( .replace(
/(<title>)(.*)(<\/title>)/, /(<title>)(.*)(<\/title>)/,
util.format('$1%s$3', config.basic.name)); util.format('$1%s$3', config.name));
fs.writeFileSync( fs.writeFileSync(
'./public/index.htm', './public/index.htm',

View file

@ -48,7 +48,7 @@ class Api {
continue; continue;
} }
const rankName = config.privileges[privilege]; const rankName = config.privileges[privilege];
const rankIndex = config.service.userRanks.indexOf(rankName); const rankIndex = config.ranks.indexOf(rankName);
if (minViableRank === null || rankIndex < minViableRank) { if (minViableRank === null || rankIndex < minViableRank) {
minViableRank = rankIndex; minViableRank = rankIndex;
} }
@ -57,7 +57,7 @@ class Api {
console.error('Bad privilege name: ' + lookup); console.error('Bad privilege name: ' + lookup);
} }
let myRank = this.user !== null ? let myRank = this.user !== null ?
config.service.userRanks.indexOf(this.user.accessRank) : config.ranks.indexOf(this.user.rank) :
0; 0;
return myRank >= minViableRank; return myRank >= minViableRank;
} }
@ -91,7 +91,7 @@ class Api {
} }
getFullUrl(url) { getFullUrl(url) {
return (config.basic.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/'); return (config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
} }
} }

View file

@ -25,7 +25,7 @@ class HelpView extends BaseView {
} }
const content = this.sectionTemplates[section]({ const content = this.sectionTemplates[section]({
'name': config.basic.name, 'name': config.name,
}); });
this.showView(this.template({'content': content})); this.showView(this.template({'content': content}));

View file

@ -11,7 +11,7 @@ class HomeView extends BaseView {
render(section) { render(section) {
this.showView(this.template({ this.showView(this.template({
name: config.basic.name, name: config.name,
version: config.meta.version, version: config.meta.version,
buildDate: config.meta.buildDate, buildDate: config.meta.buildDate,
})); }));

View file

@ -17,8 +17,8 @@ class LoginView extends BaseView {
const userNameField = document.getElementById('user-name'); const userNameField = document.getElementById('user-name');
const passwordField = document.getElementById('user-password'); const passwordField = document.getElementById('user-password');
const rememberUserField = document.getElementById('remember-user'); const rememberUserField = document.getElementById('remember-user');
userNameField.setAttribute('pattern', config.service.userNameRegex); userNameField.setAttribute('pattern', config.userNameRegex);
passwordField.setAttribute('pattern', config.service.passwordRegex); passwordField.setAttribute('pattern', config.passwordRegex);
form.addEventListener('submit', e => { form.addEventListener('submit', e => {
e.preventDefault(); e.preventDefault();

View file

@ -17,8 +17,8 @@ class RegistrationView extends BaseView {
const userNameField = document.getElementById('user-name'); const userNameField = document.getElementById('user-name');
const passwordField = document.getElementById('user-password'); const passwordField = document.getElementById('user-password');
const emailField = document.getElementById('user-email'); const emailField = document.getElementById('user-email');
userNameField.setAttribute('pattern', config.service.userNameRegex); userNameField.setAttribute('pattern', config.userNameRegex);
passwordField.setAttribute('pattern', config.service.passwordRegex); passwordField.setAttribute('pattern', config.passwordRegex);
form.addEventListener('submit', e => { form.addEventListener('submit', e => {
e.preventDefault(); e.preventDefault();

View file

@ -7,13 +7,13 @@
}, },
"dependencies": { "dependencies": {
"browserify": "^13.0.0", "browserify": "^13.0.0",
"camelcase-keys": "^2.1.0", "camelcase": "^2.1.1",
"csso": "^1.8.0", "csso": "^1.8.0",
"glob": "^7.0.3", "glob": "^7.0.3",
"handlebars": "^4.0.5", "handlebars": "^4.0.5",
"html-minifier": "^1.3.1", "html-minifier": "^1.3.1",
"ini": "^1.3.4",
"js-cookie": "^2.1.0", "js-cookie": "^2.1.0",
"js-yaml": "^3.5.5",
"merge": "^1.2.0", "merge": "^1.2.0",
"page": "^1.7.1", "page": "^1.7.1",
"superagent": "^1.8.3", "superagent": "^1.8.3",

View file

@ -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

102
config.yaml.dist Normal file
View file

@ -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

View file

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

View file

@ -19,12 +19,12 @@ class PasswordResetApi(BaseApi):
'User %r hasn\'t supplied email. Cannot reset password.' % user_name) 'User %r hasn\'t supplied email. Cannot reset password.' % user_name)
token = auth.generate_authentication_token(user) token = auth.generate_authentication_token(user)
url = '%s/password-reset/%s:%s' % ( 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( mailer.send_mail(
'noreply@%s' % config.config['basic']['name'], 'noreply@%s' % config.config['name'],
user.email, user.email,
MAIL_SUBJECT.format(name=config.config['basic']['name']), MAIL_SUBJECT.format(name=config.config['name']),
MAIL_BODY.format(name=config.config['basic']['name'], url=url)) MAIL_BODY.format(name=config.config['name'], url=url))
return {} return {}
def post(self, context, user_name): def post(self, context, user_name):

View file

@ -1,13 +1,26 @@
import os import os
import configobj import yaml
from szurubooru import errors 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): class Config(object):
''' INI config parser and container. ''' ''' Config parser and container. '''
def __init__(self): def __init__(self):
self.config = configobj.ConfigObj('../config.ini.dist') with open('../config.yaml.dist') as handle:
if os.path.exists('../config.ini'): self.config = yaml.load(handle.read())
self.config.merge(configobj.ConfigObj('../config.ini')) if os.path.exists('../config.yaml'):
with open('../config.yaml') as handle:
self.config = merge(self.config, yaml.load(handle.read()))
self._validate() self._validate()
def __getitem__(self, key): def __getitem__(self, key):
@ -15,22 +28,25 @@ class Config(object):
def _validate(self): 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. lethal at runtime.
''' '''
all_ranks = self['service']['user_ranks'] all_ranks = self['ranks']
for privilege, rank in self['privileges'].items(): for privilege, rank in self['privileges'].items():
if rank not in all_ranks: if rank not in all_ranks:
raise errors.ConfigError( raise errors.ConfigError(
'Rank %r for privilege %r is missing from user_ranks' % ( 'Rank %r for privilege %r is missing' % (rank, privilege))
rank, privilege))
for rank in ['anonymous', 'admin', 'nobody']: for rank in ['anonymous', 'admin', 'nobody']:
if rank not in all_ranks: if rank not in all_ranks:
raise errors.ConfigError( raise errors.ConfigError('Protected rank %r is missing' % rank)
'Fixed rank %r is missing from user_ranks' % rank) if self['default_rank'] not in all_ranks:
if self['service']['default_user_rank'] not in all_ranks:
raise errors.ConfigError( raise errors.ConfigError(
'Default rank %r is missing from user_ranks' % ( 'Default rank %r is not on the list of known ranks' % (
self['service']['default_user_rank'])) 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 config = Config() # pylint: disable=invalid-name

View file

@ -9,11 +9,9 @@ class TestPasswordReset(DatabaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
config_mock = { config_mock = {
'basic': { 'secret': 'x',
'secret': 'x', 'base_url': 'http://example.com/',
'base_url': 'http://example.com/', 'name': 'Test instance',
'name': 'Test instance',
},
} }
self.old_config = config.config self.old_config = config.config
config.config = config_mock config.config = config_mock

View file

@ -13,9 +13,7 @@ class TestRetrievingUsers(DatabaseTestCase):
'users:view': 'regular_user', 'users:view': 'regular_user',
'users:create': 'regular_user', 'users:create': 'regular_user',
}, },
'service': { 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'user_ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
},
} }
self.old_config = config.config self.old_config = config.config
config.config = config_mock config.config = config_mock
@ -74,15 +72,11 @@ class TestCreatingUser(DatabaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
config_mock = { config_mock = {
'basic': { 'secret': '',
'secret': '', 'user_name_regex': '.{3,}',
}, 'password_regex': '.{3,}',
'service': { 'default_rank': 'regular_user',
'user_name_regex': '.{3,}', 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'password_regex': '.{3,}',
'user_ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'default_user_rank': 'regular_user',
},
'privileges': { 'privileges': {
'users:create': 'anonymous', 'users:create': 'anonymous',
}, },
@ -146,14 +140,10 @@ class TestUpdatingUser(DatabaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
config_mock = { config_mock = {
'basic': { 'secret': '',
'secret': '', 'user_name_regex': '.{3,}',
}, 'password_regex': '.{3,}',
'service': { 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
'user_name_regex': '.{3,}',
'password_regex': '.{3,}',
'user_ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
},
'privileges': { 'privileges': {
'users:edit:self:name': 'regular_user', 'users:edit:self:name': 'regular_user',
'users:edit:self:pass': 'regular_user', 'users:edit:self:pass': 'regular_user',

View file

@ -6,7 +6,7 @@ from szurubooru import errors
def get_password_hash(salt, password): def get_password_hash(salt, password):
''' Retrieve new-style password hash. ''' ''' Retrieve new-style password hash. '''
digest = hashlib.sha256() 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(salt.encode('utf8'))
digest.update(password.encode('utf8')) digest.update(password.encode('utf8'))
return digest.hexdigest() 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. 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 privilege_name in config.config['privileges']
assert user.rank in all_ranks assert user.rank in all_ranks
@ -54,6 +54,6 @@ def verify_privilege(user, privilege_name):
def generate_authentication_token(user): def generate_authentication_token(user):
''' Generate nonguessable challenge (e.g. links in password reminder). ''' ''' Generate nonguessable challenge (e.g. links in password reminder). '''
digest = hashlib.md5() 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')) digest.update(user.password_salt.encode('utf8'))
return digest.hexdigest() return digest.hexdigest()

View file

@ -10,7 +10,7 @@ def create_user(name, password, email):
update_name(user, name) update_name(user, name)
update_password(user, password) update_password(user, password)
update_email(user, email) update_email(user, email)
user.rank = config.config['service']['default_user_rank'] user.rank = config.config['default_rank']
user.creation_time = datetime.now() user.creation_time = datetime.now()
user.avatar_style = db.User.AVATAR_GRAVATAR user.avatar_style = db.User.AVATAR_GRAVATAR
return user return user
@ -18,7 +18,7 @@ def create_user(name, password, email):
def update_name(user, name): def update_name(user, name):
''' Validate and update user's name. ''' ''' Validate and update user's name. '''
name = name.strip() 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): if not re.match(name_regex, name):
raise errors.ValidationError( raise errors.ValidationError(
'Name must satisfy regex %r.' % name_regex) 'Name must satisfy regex %r.' % name_regex)
@ -26,7 +26,7 @@ def update_name(user, name):
def update_password(user, password): def update_password(user, password):
''' Validate and update user's 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): if not re.match(password_regex, password):
raise errors.ValidationError( raise errors.ValidationError(
'Password must satisfy regex %r.' % password_regex) 'Password must satisfy regex %r.' % password_regex)
@ -43,10 +43,10 @@ def update_email(user, email):
def update_rank(user, rank, authenticated_user): def update_rank(user, rank, authenticated_user):
rank = rank.strip() rank = rank.strip()
available_ranks = config.config['service']['user_ranks'] available_ranks = config.config['ranks']
if not rank in available_ranks: if not rank in available_ranks:
raise errors.ValidationError( 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) \ if available_ranks.index(authenticated_user.rank) \
< available_ranks.index(rank): < available_ranks.index(rank):
raise errors.AuthError('Trying to set higher rank than your own') raise errors.AuthError('Trying to set higher rank than your own')