diff --git a/.envrc b/.envrc index c517f69..012a1ce 100644 --- a/.envrc +++ b/.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 diff --git a/.gitignore b/.gitignore index 8eb4757..63ea6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ /snikket_web/static/css/*.css /snikket_web/translations/messages.pot /snikket_web/translations/*/LC_MESSAGES/*.mo +/.env diff --git a/README.md b/README.md index e9d432f..7b9cd75 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,34 @@ ![Screenshot of the app](docs/readme-screenshot.png) -## 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`. diff --git a/build-requirements.txt b/build-requirements.txt index e9db215..31f7498 100644 --- a/build-requirements.txt +++ b/build-requirements.txt @@ -1,2 +1,3 @@ pyscss~=1.3 mypy +python-dotenv~=0.15 diff --git a/docker/Dockerfile b/docker/Dockerfile index 6419329..2ea566e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/env.py b/docker/env.py new file mode 100644 index 0000000..ff62c3f --- /dev/null +++ b/docker/env.py @@ -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() diff --git a/web_config.example.py b/example.env similarity index 67% rename from web_config.example.py rename to example.env index 88e8acd..a1a85f4 100644 --- a/web_config.example.py +++ b/example.env @@ -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 diff --git a/example.env.py b/example.env.py new file mode 100644 index 0000000..1d8127a --- /dev/null +++ b/example.env.py @@ -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 diff --git a/mypy.ini b/mypy.ini index 702ac3c..07cdf21 100644 --- a/mypy.ini +++ b/mypy.ini @@ -29,3 +29,6 @@ ignore_missing_imports = True [mypy-wtforms.*] ignore_missing_imports = True + +[mypy-environ.*] +ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index 82b0b21..36d31e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/snikket_web/__init__.py b/snikket_web/__init__.py index ed692d7..6d98bb7 100644 --- a/snikket_web/__init__.py +++ b/snikket_web/__init__.py @@ -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") diff --git a/web_config.production.py b/web_config.production.py deleted file mode 100644 index 372d14a..0000000 --- a/web_config.production.py +++ /dev/null @@ -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"]