From c0f53c8e6e2431ca021f19be291eb72871b84d18 Mon Sep 17 00:00:00 2001 From: Eloi Rivard Date: Thu, 28 Oct 2021 15:24:34 +0200 Subject: [PATCH] use flask-themer to allow theme customization --- README.md | 1 + canaille/__init__.py | 20 +++- canaille/account.py | 2 +- canaille/admin/authorizations.py | 3 +- canaille/admin/clients.py | 3 +- canaille/admin/mail.py | 3 +- canaille/admin/tokens.py | 3 +- canaille/conf/config.sample.toml | 5 + canaille/configuration.py | 29 ++++- canaille/consents.py | 3 +- canaille/groups.py | 2 +- canaille/mails.py | 3 +- canaille/oauth.py | 2 +- canaille/templates/about.html | 2 +- .../templates/admin/authorization_list.html | 2 +- .../templates/admin/authorization_view.html | 2 +- canaille/templates/admin/client_add.html | 2 +- canaille/templates/admin/client_edit.html | 2 +- canaille/templates/admin/client_list.html | 2 +- canaille/templates/admin/token_list.html | 2 +- canaille/templates/admin/token_view.html | 2 +- canaille/templates/authorize.html | 2 +- canaille/templates/consent_list.html | 2 +- canaille/templates/error.html | 2 +- canaille/templates/firstlogin.html | 2 +- canaille/templates/forgotten-password.html | 2 +- canaille/templates/group.html | 2 +- canaille/templates/groups.html | 2 +- canaille/templates/home.html | 2 +- canaille/templates/login.html | 2 +- canaille/templates/password.html | 2 +- canaille/templates/profile.html | 2 +- canaille/templates/reset-password.html | 2 +- canaille/templates/users.html | 2 +- .../{templates => themes/default}/base.html | 0 demo/conf/canaille.toml | 5 + setup.cfg | 1 + tests/fixtures/themes/test/base.html | 107 ++++++++++++++++++ tests/test_configuration.py | 43 +++++++ 39 files changed, 240 insertions(+), 37 deletions(-) rename canaille/{templates => themes/default}/base.html (100%) create mode 100644 tests/fixtures/themes/test/base.html diff --git a/README.md b/README.md index 8cec8cf7..845b7475 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ It aims to be very light, simple to install and simple to maintain. Its main fea - OpenID Connect support; - No outdated or exotic protocol support; - No additional database required. Everything is stored in your LDAP server; +- Customizable, themable; - The code is easy to read and easy to edit, and you should! # Screenshots diff --git a/canaille/__init__.py b/canaille/__init__.py index 8415570d..410972c1 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -15,8 +15,9 @@ import canaille.account import canaille.groups import canaille.well_known -from flask import Flask, g, request, render_template, session +from flask import Flask, g, request, session from flask_babel import Babel +from flask_themer import Themer, render_template, FileSystemThemeLoader from .flaskutils import current_user from .ldaputils import LDAPObject @@ -120,6 +121,18 @@ def setup_app(app): babel = Babel(app) + additional_themes_dir = ( + os.path.dirname(app.config["THEME"]) + if app.config.get("THEME") and os.path.exists(app.config["THEME"]) + else None + ) + themer = Themer( + app, + loaders=[FileSystemThemeLoader(additional_themes_dir)] + if additional_themes_dir + else None, + ) + @babel.localeselector def get_locale(): user = getattr(g, "user", None) @@ -137,6 +150,11 @@ def setup_app(app): if user is not None: return user.timezone + @themer.current_theme_loader + def get_current_theme(): + # if config['THEME'] may be a theme name or an absolute path + return app.config.get("THEME", "default").split("/")[-1] + @app.before_request def before_request(): setup_ldap_connection(app) diff --git a/canaille/account.py b/canaille/account.py index f6f14cad..cb129d53 100644 --- a/canaille/account.py +++ b/canaille/account.py @@ -7,11 +7,11 @@ from flask import ( url_for, current_app, abort, - render_template, redirect, session, ) from flask_babel import gettext as _ +from flask_themer import render_template from werkzeug.datastructures import CombinedMultiDict, FileStorage from .forms import ( LoginForm, diff --git a/canaille/admin/authorizations.py b/canaille/admin/authorizations.py index c8a745c7..5003544d 100644 --- a/canaille/admin/authorizations.py +++ b/canaille/admin/authorizations.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template +from flask import Blueprint +from flask_themer import render_template from canaille.models import AuthorizationCode from canaille.flaskutils import admin_needed diff --git a/canaille/admin/clients.py b/canaille/admin/clients.py index 3c473340..43ede5b9 100644 --- a/canaille/admin/clients.py +++ b/canaille/admin/clients.py @@ -1,6 +1,7 @@ import datetime import wtforms -from flask import Blueprint, render_template, request, flash, redirect, url_for, abort +from flask import Blueprint, request, flash, redirect, url_for, abort +from flask_themer import render_template from flask_wtf import FlaskForm from flask_babel import lazy_gettext as _ from werkzeug.security import gen_salt diff --git a/canaille/admin/mail.py b/canaille/admin/mail.py index 3cf39245..ecaddbdb 100644 --- a/canaille/admin/mail.py +++ b/canaille/admin/mail.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template, current_app, url_for +from flask import Blueprint, current_app, url_for +from flask_themer import render_template from flask_babel import gettext as _ from canaille.flaskutils import admin_needed from canaille.mails import profile_hash diff --git a/canaille/admin/tokens.py b/canaille/admin/tokens.py index e4019d12..209fd648 100644 --- a/canaille/admin/tokens.py +++ b/canaille/admin/tokens.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template +from flask import Blueprint +from flask_themer import render_template from canaille.models import Token from canaille.flaskutils import admin_needed diff --git a/canaille/conf/config.sample.toml b/canaille/conf/config.sample.toml index 9a6f93d0..c4d5d7ae 100644 --- a/canaille/conf/config.sample.toml +++ b/canaille/conf/config.sample.toml @@ -17,6 +17,11 @@ LOGO = "/static/img/canaille-head.png" # Your favicon. If unset the LOGO will be used. FAVICON = "/static/img/canaille-c.png" +# The name of a theme in the 'theme' directory, or an absolute path +# to a theme. Defaults to 'default'. Theming is done with +# https://github.com/tktech/flask-themer +# THEME = "default" + # If unset, language is detected # LANGUAGE = "en" diff --git a/canaille/configuration.py b/canaille/configuration.py index a1f64239..8316db01 100644 --- a/canaille/configuration.py +++ b/canaille/configuration.py @@ -8,12 +8,25 @@ from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend as crypto_default_backend +ROOT = os.path.dirname(os.path.abspath(__file__)) + class ConfigurationException(Exception): pass def validate(config, validate_remote=False): + validate_keypair(config) + validate_theme(config) + + if not validate_remote: + return + + validate_ldap_configuration(config) + validate_smtp_configuration(config) + + +def validate_keypair(config): if not os.path.exists(config["JWT"]["PUBLIC_KEY"]): raise ConfigurationException( f'Public key does not exist {config["JWT"]["PUBLIC_KEY"]}' @@ -24,12 +37,6 @@ def validate(config, validate_remote=False): f'Private key does not exist {config["JWT"]["PRIVATE_KEY"]}' ) - if not validate_remote: - return - - validate_ldap_configuration(config) - validate_smtp_configuration(config) - def validate_ldap_configuration(config): from canaille.models import User, Group @@ -127,6 +134,16 @@ def validate_smtp_configuration(config): ) from exc +def validate_theme(config): + if not config.get("THEME"): + return + + if not os.path.exists(config["THEME"]) and not os.path.exists( + os.path.join(ROOT, "themes", config["THEME"]) + ): + raise ConfigurationException(f'Cannot find theme \'{config["THEME"]}\'') + + def setup_dev_keypair(config): if os.path.exists(config["JWT"]["PUBLIC_KEY"]) or os.path.exists( config["JWT"]["PRIVATE_KEY"] diff --git a/canaille/consents.py b/canaille/consents.py index 9f82a3ab..051f3388 100644 --- a/canaille/consents.py +++ b/canaille/consents.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template, flash, redirect, url_for +from flask import Blueprint, flash, redirect, url_for +from flask_themer import render_template from flask_babel import gettext from canaille.models import Consent, Client from canaille.flaskutils import user_needed diff --git a/canaille/groups.py b/canaille/groups.py index 0c7882f9..c90c449d 100644 --- a/canaille/groups.py +++ b/canaille/groups.py @@ -1,6 +1,5 @@ from flask import ( Blueprint, - render_template, redirect, url_for, request, @@ -9,6 +8,7 @@ from flask import ( abort, ) from flask_babel import gettext as _ +from flask_themer import render_template from .flaskutils import moderator_needed from .forms import GroupForm diff --git a/canaille/mails.py b/canaille/mails.py index 3d3f23fc..94ffcebb 100644 --- a/canaille/mails.py +++ b/canaille/mails.py @@ -1,6 +1,7 @@ import hashlib -from flask import url_for, render_template, current_app +from flask import url_for, current_app from flask_babel import gettext as _ +from flask_themer import render_template from .apputils import logo, send_email diff --git a/canaille/oauth.py b/canaille/oauth.py index 42601dbe..fa2417ee 100644 --- a/canaille/oauth.py +++ b/canaille/oauth.py @@ -9,11 +9,11 @@ from flask import ( session, redirect, abort, - render_template, jsonify, flash, ) from flask_babel import gettext, lazy_gettext as _ +from flask_themer import render_template from .models import User, Client, Consent from .oauth2utils import ( authorization, diff --git a/canaille/templates/about.html b/canaille/templates/about.html index d631471b..5f2c8ff0 100644 --- a/canaille/templates/about.html +++ b/canaille/templates/about.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/admin/authorization_list.html b/canaille/templates/admin/authorization_list.html index dbf83c66..c254cd25 100644 --- a/canaille/templates/admin/authorization_list.html +++ b/canaille/templates/admin/authorization_list.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block style %} diff --git a/canaille/templates/admin/authorization_view.html b/canaille/templates/admin/authorization_view.html index 3424a132..3ad31082 100644 --- a/canaille/templates/admin/authorization_view.html +++ b/canaille/templates/admin/authorization_view.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/admin/client_add.html b/canaille/templates/admin/client_add.html index e628f4e2..0c9b1c4f 100644 --- a/canaille/templates/admin/client_add.html +++ b/canaille/templates/admin/client_add.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/admin/client_edit.html b/canaille/templates/admin/client_edit.html index 9edd16e3..0a7c602d 100644 --- a/canaille/templates/admin/client_edit.html +++ b/canaille/templates/admin/client_edit.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block script %} diff --git a/canaille/templates/admin/client_list.html b/canaille/templates/admin/client_list.html index 0152d9a6..23671074 100644 --- a/canaille/templates/admin/client_list.html +++ b/canaille/templates/admin/client_list.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block style %} diff --git a/canaille/templates/admin/token_list.html b/canaille/templates/admin/token_list.html index 7860f9fa..67fd89a2 100644 --- a/canaille/templates/admin/token_list.html +++ b/canaille/templates/admin/token_list.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block style %} diff --git a/canaille/templates/admin/token_view.html b/canaille/templates/admin/token_view.html index 2d8980c1..79038e48 100644 --- a/canaille/templates/admin/token_view.html +++ b/canaille/templates/admin/token_view.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/authorize.html b/canaille/templates/authorize.html index 40e3c9a8..2661df74 100644 --- a/canaille/templates/authorize.html +++ b/canaille/templates/authorize.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block content %}
diff --git a/canaille/templates/consent_list.html b/canaille/templates/consent_list.html index 14380fd9..bca033fc 100644 --- a/canaille/templates/consent_list.html +++ b/canaille/templates/consent_list.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block style %} diff --git a/canaille/templates/error.html b/canaille/templates/error.html index fa456f43..43aa1903 100644 --- a/canaille/templates/error.html +++ b/canaille/templates/error.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block content %}
diff --git a/canaille/templates/firstlogin.html b/canaille/templates/firstlogin.html index bc02cdbb..b83c2136 100644 --- a/canaille/templates/firstlogin.html +++ b/canaille/templates/firstlogin.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/forgotten-password.html b/canaille/templates/forgotten-password.html index 2d5bb676..0a6d7799 100644 --- a/canaille/templates/forgotten-password.html +++ b/canaille/templates/forgotten-password.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/group.html b/canaille/templates/group.html index a5973597..1373fb2a 100644 --- a/canaille/templates/group.html +++ b/canaille/templates/group.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block script %} diff --git a/canaille/templates/groups.html b/canaille/templates/groups.html index 94d87286..91bd134b 100644 --- a/canaille/templates/groups.html +++ b/canaille/templates/groups.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block content %}
diff --git a/canaille/templates/home.html b/canaille/templates/home.html index 2e654ea7..d2e57f4b 100644 --- a/canaille/templates/home.html +++ b/canaille/templates/home.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block content %}
diff --git a/canaille/templates/login.html b/canaille/templates/login.html index 1f745128..b60f171a 100644 --- a/canaille/templates/login.html +++ b/canaille/templates/login.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/password.html b/canaille/templates/password.html index 51e4007f..02521981 100644 --- a/canaille/templates/password.html +++ b/canaille/templates/password.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/profile.html b/canaille/templates/profile.html index a3f8bf9e..3b815589 100644 --- a/canaille/templates/profile.html +++ b/canaille/templates/profile.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block script %} diff --git a/canaille/templates/reset-password.html b/canaille/templates/reset-password.html index f7625706..1c1101fa 100644 --- a/canaille/templates/reset-password.html +++ b/canaille/templates/reset-password.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% import 'fomanticui.j2' as sui %} {% block content %} diff --git a/canaille/templates/users.html b/canaille/templates/users.html index 3adb6888..4f7736c7 100644 --- a/canaille/templates/users.html +++ b/canaille/templates/users.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends theme('base.html') %} {% block style %} diff --git a/canaille/templates/base.html b/canaille/themes/default/base.html similarity index 100% rename from canaille/templates/base.html rename to canaille/themes/default/base.html diff --git a/demo/conf/canaille.toml b/demo/conf/canaille.toml index 4bfdfb02..3180b564 100644 --- a/demo/conf/canaille.toml +++ b/demo/conf/canaille.toml @@ -17,6 +17,11 @@ LOGO = "/static/img/canaille-head.png" # Your favicon. If unset the LOGO will be used. FAVICON = "/static/img/canaille-c.png" +# The name of a theme in the 'theme' directory, or an absolute path +# to a theme. Defaults to 'default'. Theming is done with +# https://github.com/tktech/flask-themer +# THEME = "default" + # If unset, language is detected # LANGUAGE = "en" diff --git a/setup.cfg b/setup.cfg index df9dda7b..16d6a0a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ install_requires = email_validator flask flask-babel + flask-themer flask-wtf python-ldap sentry-sdk[flask] diff --git a/tests/fixtures/themes/test/base.html b/tests/fixtures/themes/test/base.html new file mode 100644 index 00000000..497c43ab --- /dev/null +++ b/tests/fixtures/themes/test/base.html @@ -0,0 +1,107 @@ +{% import 'flask.j2' as flask %} + + + + + + + + + {% block title %}{{ website_name|default("Canaille") }} {% trans %}authorization interface{% endtrans %}{% endblock %} + + + + + {% if logo_url %}{% endif %} + {% block style %}{% endblock %} + + + + + + + {% block menu %} + {% if user and menu %} + + {% endif %} + {% endblock %} + +
+
+ {{ flask.messages() }} + {% block content %}{% endblock %} +
+
+ + + + + + {% block script %}{% endblock %} + + TEST_THEME + + diff --git a/tests/test_configuration.py b/tests/test_configuration.py index fc86753b..fa8a43a9 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1,8 +1,11 @@ import ldap import mock +import os import pytest +from canaille import create_app from canaille.commands import cli from canaille.configuration import validate, ConfigurationException +from flask_webtest import TestApp def test_ldap_connection_no_remote(configuration): @@ -104,3 +107,43 @@ def test_check_command_fail(testclient): testclient.app.config["LDAP"]["URI"] = "ldap://invalid-ldap.com" runner = testclient.app.test_cli_runner() runner.invoke(cli, ["check"]) + + +@pytest.fixture +def themed_testclient(app, configuration): + configuration["TESTING"] = True + + root = os.path.dirname(os.path.abspath(__file__)) + test_theme_path = os.path.join(root, "fixtures", "themes", "test") + configuration["THEME"] = test_theme_path + + configuration["AUTHLIB_INSECURE_TRANSPORT"] = "true" + app = create_app(configuration) + + return TestApp(app) + + +def test_theme(testclient, themed_testclient): + res = testclient.get("/login") + assert "TEST_THEME" not in res + + res = themed_testclient.get("/login") + assert "TEST_THEME" in res + + +def test_invalid_theme(configuration): + validate(configuration, validate_remote=False) + + with pytest.raises( + ConfigurationException, + match=r"Cannot find theme", + ): + configuration["THEME"] = "invalid" + validate(configuration, validate_remote=False) + + with pytest.raises( + ConfigurationException, + match=r"Cannot find theme", + ): + configuration["THEME"] = "/path/to/invalid" + validate(configuration, validate_remote=False)