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/

View file

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

View file

@ -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>)(.*)(<\/title>)/,
util.format('$1%s$3', config.basic.name));
util.format('$1%s$3', config.name));
fs.writeFileSync(
'./public/index.htm',

View file

@ -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/');
}
}

View file

@ -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}));

View file

@ -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,
}));

View file

@ -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();

View file

@ -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();

View file

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

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
configobj>=5.0.6
pyyaml>=3.11
falcon>=0.3.0
psycopg2>=2.6.1
SQLAlchemy>=1.0.12

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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