You've already forked snikket-web-portal
12-factorize application a little
snikket_web can now be fully configured via the environment alone, no extra files needed. It is still supported to inject a python file to generate environment variables though, which may be useful for generating and reloading a secret key.
This commit is contained in:
2
.envrc
2
.envrc
@@ -2,4 +2,4 @@ layout python3
|
||||
|
||||
export QUART_APP='snikket_web:create_app()'
|
||||
export QUART_ENV=development
|
||||
export SNIKKET_WEB_CONFIG="$(pwd)/.local/web_config.py"
|
||||
export QUART_DEBUG=1
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ __pycache__
|
||||
/snikket_web/static/css/*.css
|
||||
/snikket_web/translations/messages.pot
|
||||
/snikket_web/translations/*/LC_MESSAGES/*.mo
|
||||
/.env
|
||||
|
||||
27
README.md
27
README.md
@@ -2,15 +2,34 @@
|
||||
|
||||

|
||||
|
||||
## Start the dev server
|
||||
## Development quickstart
|
||||
|
||||
```console
|
||||
$ direnv allow
|
||||
$ mkdir .local
|
||||
$ cp web_config.example.py .local/web_config.py
|
||||
$ $EDITOR .local/web_config.py # to adapt the configuration to your needs
|
||||
$ cp example.env .env
|
||||
$ $EDITOR .env # to adapt the configuration to your needs
|
||||
$ pip install -r requirements.txt
|
||||
$ pip install -r build-requirements.txt
|
||||
$ make
|
||||
$ quart run
|
||||
```
|
||||
|
||||
## Configuring
|
||||
|
||||
### Purely via environment variables
|
||||
|
||||
For a list of required and understood environment variables as well as their
|
||||
semantics, please refer to [`example.env`](example.env).
|
||||
|
||||
### Via python code
|
||||
|
||||
In addition to statically setting environment variables, it is possible to
|
||||
initialise the environment variables in a python file. To do that, pass the
|
||||
path to the python file as `SNIKKET_WEB_PYENV` environment variable.
|
||||
|
||||
The python file is evaluated before further environment variable processing
|
||||
takes place. Every name defined in that file which begins with an upper case
|
||||
ASCII letter is included in the processing of environment variables for
|
||||
configuration purposes.
|
||||
|
||||
For a (non-productive) example of such a file, see `example.env.py`.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pyscss~=1.3
|
||||
mypy
|
||||
python-dotenv~=0.15
|
||||
|
||||
@@ -45,8 +45,8 @@ RUN set -eu; \
|
||||
rm -rf /root/.cache; \
|
||||
apt-get clean ; rm -rf /var/lib/apt/lists
|
||||
|
||||
COPY web_config.production.py /opt/snikket-web-portal/.local/web_config.py
|
||||
ENV SNIKKET_WEB_CONFIG "/opt/snikket-web-portal/.local/web_config.py"
|
||||
COPY docker/env.py /etc/snikket-web-portal/env.py
|
||||
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
|
||||
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||
|
||||
25
docker/env.py
Normal file
25
docker/env.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
_secret_key_path = "/etc/snikket-web-portal/secret_key"
|
||||
|
||||
if "SNIKKET_WEB_SECRET_KEY" in os.environ:
|
||||
print("Using SNIKKET_WEB_SECRET_KEY from environment")
|
||||
else:
|
||||
try:
|
||||
with open(_secret_key_path, "r") as f:
|
||||
SNIKKET_WEB_SECRET_KEY = f.read()
|
||||
print("Restored SNIKKET_WEB_SECRET_KEY from", _secret_key_path)
|
||||
except FileNotFoundError:
|
||||
print("Generating SNIKKET_WEB_SECRET_KEY ...")
|
||||
SNIKKET_WEB_SECRET_KEY = secrets.token_urlsafe(nbytes=32)
|
||||
old_mask = os.umask(0o077)
|
||||
with open(_secret_key_path, "x") as f:
|
||||
f.write(SNIKKET_WEB_SECRET_KEY)
|
||||
os.umask(old_mask)
|
||||
print("SNIKKET_WEB_SECRET_KEY persisted to", _secret_key_path)
|
||||
|
||||
# Ensure that the above output is printed, even if nothing else is.
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
@@ -15,8 +15,7 @@
|
||||
# - attackers may be able to execute things on a properly authenticated user’s
|
||||
# behalf.
|
||||
# - other bad things.
|
||||
import secrets
|
||||
SECRET_KEY = secrets.token_urlsafe(nbytes=32)
|
||||
SNIKKET_WEB_SECRET_KEY=
|
||||
|
||||
# URL (without trailing /) of the prosody HTTP server.
|
||||
#
|
||||
@@ -24,12 +23,12 @@ SECRET_KEY = secrets.token_urlsafe(nbytes=32)
|
||||
#
|
||||
# NOTE: If this does not point at localhost, it MUST use https. Otherwise,
|
||||
# passwords will be transmitted in plaintext through insecure channels.
|
||||
PROSODY_ENDPOINT = "http://localhost:5280"
|
||||
SNIKKET_WEB_PROSODY_ENDPOINT='http://localhost:5280'
|
||||
|
||||
# The domain name of the Snikket server
|
||||
#
|
||||
# This must be set for login to work correctly.
|
||||
SNIKKET_DOMAIN = "localhost"
|
||||
SNIKKET_WEB_DOMAIN='localhost'
|
||||
|
||||
|
||||
# OPTIONAL SETTINGS
|
||||
@@ -41,14 +40,4 @@ SNIKKET_DOMAIN = "localhost"
|
||||
# of an avatar is still up-to-date on every request; if it is, the avatar is
|
||||
# not re-transferred.
|
||||
#
|
||||
# AVATAR_CACHE_TTL = 1800
|
||||
|
||||
# Which languages to offer
|
||||
#
|
||||
# Generally, the web portal will offer all languages it has available. There
|
||||
# is little point in restricting this, unless if you’re in a situation where
|
||||
# the release you’re on has a terrible translation of a specific language
|
||||
# and not offering that language at all is better than having that terrible
|
||||
# translation.
|
||||
#
|
||||
# LANGUAGES = ["de", "en"]
|
||||
# SNIKKET_WEB_AVATAR_CACHE_TTL = 1800
|
||||
11
example.env.py
Normal file
11
example.env.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# Please see example.env for a detailed list of supported environment
|
||||
# variables as well as their semantics.
|
||||
|
||||
# NOTE: this file is not meant for production use. Due to the non-constant
|
||||
# secret key, each server restart will log out all users from the web portal.
|
||||
|
||||
import secrets
|
||||
SNIKKET_WEB_SECRET_KEY = secrets.token_urlsafe(nbytes=32)
|
||||
SNIKKET_WEB_PROSODY_ENDPOINT = "http://localhost:5280"
|
||||
SNIKKET_WEB_DOMAIN = "localhost"
|
||||
# SNIKKET_WEB_AVATAR_CACHE_TTL = 1800
|
||||
3
mypy.ini
3
mypy.ini
@@ -29,3 +29,6 @@ ignore_missing_imports = True
|
||||
|
||||
[mypy-wtforms.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-environ.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
@@ -4,4 +4,5 @@ flask-wtf~=0.14
|
||||
hsluv~=0.0.2
|
||||
flask-babel~=1.0
|
||||
email-validator~=1.1
|
||||
environ-config~=20.0
|
||||
typing-extensions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import typing
|
||||
|
||||
@@ -11,6 +12,8 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
|
||||
import environ
|
||||
|
||||
from . import colour, infra
|
||||
from ._version import version, version_info # noqa:F401
|
||||
|
||||
@@ -36,10 +39,48 @@ def proc() -> typing.Dict[str, typing.Any]:
|
||||
}
|
||||
|
||||
|
||||
def autosplit(s: typing.Union[str, typing.List[str]]) -> typing.List[str]:
|
||||
if isinstance(s, str):
|
||||
return s.split()
|
||||
return s
|
||||
|
||||
|
||||
@environ.config(prefix="SNIKKET_WEB")
|
||||
class AppConfig:
|
||||
secret_key = environ.var()
|
||||
prosody_endpoint = environ.var()
|
||||
domain = environ.var()
|
||||
avatar_cache_ttl = environ.var(1800, converter=int)
|
||||
languages = environ.var(["de", "en"], converter=autosplit)
|
||||
|
||||
|
||||
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
|
||||
|
||||
|
||||
def create_app() -> quart.Quart:
|
||||
try:
|
||||
env_init = os.environ["SNIKKET_WEB_PYENV"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
import runpy
|
||||
init_vars = runpy.run_path(env_init)
|
||||
for name, value in init_vars.items():
|
||||
if not name:
|
||||
continue
|
||||
if name[0] not in _UPPER_CASE:
|
||||
continue
|
||||
os.environ[name] = value
|
||||
|
||||
config = environ.to_config(AppConfig)
|
||||
|
||||
app = quart.Quart(__name__)
|
||||
app.config.setdefault("LANGUAGES", ["de", "en"])
|
||||
app.config.from_envvar("SNIKKET_WEB_CONFIG")
|
||||
app.config["LANGUAGES"] = config.languages
|
||||
app.config["SECRET_KEY"] = config.secret_key
|
||||
app.config["PROSODY_ENDPOINT"] = config.prosody_endpoint
|
||||
app.config["SNIKKET_DOMAIN"] = config.domain
|
||||
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
|
||||
|
||||
app.context_processor(proc)
|
||||
|
||||
logging_config = app.config.get("LOGGING_CONFIG")
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# REQUIRED SETTINGS
|
||||
# =================
|
||||
|
||||
# Secret key used to guard forms and sessions.
|
||||
#
|
||||
# This must be both reasonably constant and secret. If the secret gets
|
||||
# compromised, you can change it (without having to worry about the "constant"
|
||||
# requirement).
|
||||
#
|
||||
# if not constant:
|
||||
# - sessions will be lost on each server restart
|
||||
#
|
||||
# if not secret:
|
||||
# - users may be able to forge sessions
|
||||
# - attackers may be able to execute things on a properly authenticated user’s
|
||||
# behalf.
|
||||
# - other bad things.
|
||||
import os
|
||||
import sys
|
||||
import secrets
|
||||
|
||||
try:
|
||||
SECRET_KEY = os.environ['SECRET_KEY']
|
||||
except KeyError:
|
||||
print('SECRET_KEY was not provided. It will be automatically generated. '
|
||||
'To avoid losing sessions on each server restart, please provide '
|
||||
'a SECRET_KEY.',
|
||||
file=sys.stderr)
|
||||
SECRET_KEY = secrets.token_urlsafe(nbytes=32)
|
||||
|
||||
# URL (without trailing /) of the prosody HTTP server.
|
||||
#
|
||||
# This must be set for anything to work correctly.
|
||||
#
|
||||
# NOTE: If this does not point at localhost, it MUST use https. Otherwise,
|
||||
# passwords will be transmitted in plaintext through insecure channels.
|
||||
try:
|
||||
PROSODY_ENDPOINT = os.environ['PROSODY_ENDPOINT']
|
||||
except KeyError as e:
|
||||
print(f'Environment variable {e} must be set for the web portal to work',
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# The domain name of the Snikket server
|
||||
#
|
||||
# This must be set for login to work correctly.
|
||||
try:
|
||||
SNIKKET_DOMAIN = os.environ['SNIKKET_DOMAIN']
|
||||
except KeyError as e:
|
||||
print(f'Environment variable {e} must be set for the web portal to work',
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# OPTIONAL SETTINGS
|
||||
# =================
|
||||
|
||||
# How long browers may cache avatars
|
||||
#
|
||||
# Setting this to zero forces browsers to check if their locally cached copy
|
||||
# of an avatar is still up-to-date on every request; if it is, the avatar is
|
||||
# not re-transferred.
|
||||
#
|
||||
# AVATAR_CACHE_TTL = 1800
|
||||
|
||||
# Which languages to offer
|
||||
#
|
||||
# Generally, the web portal will offer all languages it has available. There
|
||||
# is little point in restricting this, unless if you’re in a situation where
|
||||
# the release you’re on has a terrible translation of a specific language
|
||||
# and not offering that language at all is better than having that terrible
|
||||
# translation.
|
||||
#
|
||||
# LANGUAGES = ["de", "en"]
|
||||
Reference in New Issue
Block a user