diff --git a/news/PR701.feature b/news/PR701.feature new file mode 100644 index 000000000..0cee114eb --- /dev/null +++ b/news/PR701.feature @@ -0,0 +1 @@ +Allow template override with a custom directory, see the ``TEMPLATES_CUSTOM_DIRECTORIES`` configration value diff --git a/noggin.cfg.example b/noggin.cfg.example index ec933fe94..acfb93692 100644 --- a/noggin.cfg.example +++ b/noggin.cfg.example @@ -87,6 +87,21 @@ MAIL_SUPPRESS_SEND = True # will only be accessible to users that have this role. # STAGE_USERS_ROLE = "Stage User Managers" +# Additional directories to look up folders in. The templates in these directories +# will override the app's template with the same filename. You can also create the +# following templates to insert HTML in the output: +# - `after-navbar.html`: will be inserted between the navbar and the main content +# on every page +# - `before-footer.html`: will be inserted between the main content and the footer +# on every page +# - `head.html`: will be inserted at the end of the tag on every page +# TEMPLATES_CUSTOM_DIRECTORIES = [] + +# The following sources will be added to the Content Security Policy for images. +# This can be useful if you want to add images in custom templates. +# https://content-security-policy.com/img-src/ +# ACCEPT_IMAGES_FROM = [] + # Spam checking # BASSET_URL = None # SPAMCHECK_TOKEN_EXPIRATION = 60 # in minutes diff --git a/noggin/app.py b/noggin/app.py index f58c1db50..092079be7 100644 --- a/noggin/app.py +++ b/noggin/app.py @@ -2,6 +2,7 @@ from logging.config import dictConfig import flask_talisman +import jinja2 from flask import Flask from flask_healthz import healthz from flask_mail import Mail @@ -55,6 +56,15 @@ def create_app(config=None): if app.config.get("TEMPLATES_AUTO_RELOAD"): app.jinja_env.auto_reload = True + # Custom template folders + if app.config["TEMPLATES_CUSTOM_DIRECTORIES"]: + app.jinja_loader = jinja2.ChoiceLoader( + [ + jinja2.FileSystemLoader(app.config["TEMPLATES_CUSTOM_DIRECTORIES"]), + app.jinja_loader, + ] + ) + # Logging if app.config.get("LOGGING"): dictConfig(app.config["LOGGING"]) @@ -83,9 +93,13 @@ def create_app(config=None): # https://csp.withgoogle.com/docs/strict-csp.html#example "'strict-dynamic'", ], - "img-src": ["'self'", "seccdn.libravatar.org"], + "img-src": ["'self'", "seccdn.libravatar.org"] + + app.config["ACCEPT_IMAGES_FROM"], + # The style-src directive needs to be specified (even if it's the same as default-src) + # to add the nonce. + "style-src": "'self'", }, - content_security_policy_nonce_in=['script-src'], + content_security_policy_nonce_in=['script-src', 'style-src'], ) # Template filters diff --git a/noggin/defaults.py b/noggin/defaults.py index 501d14cbf..17d3065b7 100644 --- a/noggin/defaults.py +++ b/noggin/defaults.py @@ -36,6 +36,9 @@ STAGE_USERS_ROLE = "Stage User Managers" +TEMPLATES_CUSTOM_DIRECTORIES = [] +ACCEPT_IMAGES_FROM = [] + BASSET_URL = None SPAMCHECK_TOKEN_EXPIRATION = 60 # in minutes diff --git a/noggin/templates/base.html b/noggin/templates/base.html index b3969e054..38b46945f 100644 --- a/noggin/templates/base.html +++ b/noggin/templates/base.html @@ -8,10 +8,13 @@ {% block title %}{% endblock %}{% if self.title() %} - {% endif %}{% block website %}noggin{% endblock %} + {% include "head.html" ignore missing %} {% block navbar %} {% endblock %} + {% include "after-navbar.html" ignore missing %} {% block bodycontent %}{% endblock %} + {% include "before-footer.html" ignore missing %} {% block footer %}{% endblock %} {% block scripts %} {% if current_user %} diff --git a/noggin/tests/unit/test_app.py b/noggin/tests/unit/test_app.py index 2bd342b1f..37ce06b86 100644 --- a/noggin/tests/unit/test_app.py +++ b/noggin/tests/unit/test_app.py @@ -36,3 +36,33 @@ def test_logging(mocker, app_config): logging_config = mocker.patch("noggin.app.dictConfig") create_app(config) logging_config.assert_called_once_with("dummy-logging-config") + + +def test_templates_custom_directory_insertion(app_config, tmp_path): + tpl_dir = tmp_path / "templates" + os.makedirs(tpl_dir) + config = app_config.copy() + config["TEMPLATES_CUSTOM_DIRECTORIES"] = [tpl_dir] + app = create_app(config) + # Use the template placeholder + with open(tpl_dir / "after-navbar.html", "w") as tpl: + tpl.write("TESTING TEMPLATES CUSTOM DIR\n") + with app.test_client() as client: + response = client.get('/') + assert response.status_code == 200 + assert "TESTING TEMPLATES CUSTOM DIR" in response.get_data(as_text=True) + + +def test_templates_custom_directory_override(app_config, tmp_path): + tpl_dir = tmp_path / "templates" + os.makedirs(tpl_dir) + config = app_config.copy() + config["TEMPLATES_CUSTOM_DIRECTORIES"] = [tpl_dir] + app = create_app(config) + # Override the whole page template + with open(tpl_dir / "index.html", "w") as tpl: + tpl.write("TESTING TEMPLATES CUSTOM DIR\n") + with app.test_client() as client: + response = client.get('/') + assert response.status_code == 200 + assert response.get_data(as_text=True) == "TESTING TEMPLATES CUSTOM DIR"