diff options
author | Wojtek Kosior <koszko@koszko.org> | 2022-10-18 17:18:32 +0200 |
---|---|---|
committer | Wojtek Kosior <koszko@koszko.org> | 2022-10-18 20:13:45 +0200 |
commit | 14eeee3fbc0a839d918149765d2134d05cd14601 (patch) | |
tree | dec1ca7dbc8668120e53e8ea45328246313199d1 /src/hydrilla/proxy/web_ui | |
parent | 76f4b6769d0acaeffbd8e8a003fcbb3e2fbea107 (diff) | |
download | haketilo-hydrilla-14eeee3fbc0a839d918149765d2134d05cd14601.tar.gz haketilo-hydrilla-14eeee3fbc0a839d918149765d2134d05cd14601.zip |
[proxy] upon Haketilo launch automatically open Haketilo landing page in user's default web browser
* The landing page instructs user to configure browser's proxy settings.
* It is now possible to choose the IP address to listen on via command line parameter.
* The browser launching behavior can be switched off via command line parameter.
Diffstat (limited to 'src/hydrilla/proxy/web_ui')
19 files changed, 252 insertions, 119 deletions
diff --git a/src/hydrilla/proxy/web_ui/__init__.py b/src/hydrilla/proxy/web_ui/__init__.py index a5dddab..1ae5dba 100644 --- a/src/hydrilla/proxy/web_ui/__init__.py +++ b/src/hydrilla/proxy/web_ui/__init__.py @@ -4,4 +4,5 @@ # # Available under the terms of Creative Commons Zero v1.0 Universal. +from ._app import UIDomain from .root import process_request diff --git a/src/hydrilla/proxy/web_ui/_app.py b/src/hydrilla/proxy/web_ui/_app.py index ab15918..f54f72e 100644 --- a/src/hydrilla/proxy/web_ui/_app.py +++ b/src/hydrilla/proxy/web_ui/_app.py @@ -4,6 +4,8 @@ # # Available under the terms of Creative Commons Zero v1.0 Universal. +import enum +import dataclasses as dc import typing as t import flask @@ -11,8 +13,17 @@ import flask from .. import state as st +class UIDomain(enum.Enum): + MAIN = enum.auto() + LANDING_PAGE = enum.auto() + +@dc.dataclass(init=False) class WebUIApp(flask.Flask): - _haketilo_state: st.HaketiloState + _haketilo_state: st.HaketiloState + _haketilo_ui_domain: t.ClassVar[UIDomain] def get_haketilo_state() -> st.HaketiloState: return t.cast(WebUIApp, flask.current_app)._haketilo_state + +def get_haketilo_ui_domain() -> UIDomain: + return t.cast(WebUIApp, flask.current_app)._haketilo_ui_domain diff --git a/src/hydrilla/proxy/web_ui/root.py b/src/hydrilla/proxy/web_ui/root.py index 18ea18e..57dc958 100644 --- a/src/hydrilla/proxy/web_ui/root.py +++ b/src/hydrilla/proxy/web_ui/root.py @@ -29,6 +29,7 @@ ..... """ +import dataclasses as dc import typing as t from threading import Lock @@ -71,10 +72,21 @@ def get_settings() -> st.HaketiloGlobalSettings: return _app.get_haketilo_state().get_settings() +@dc.dataclass(init=False) class WebUIAppImpl(_app.WebUIApp): + # Flask app is not thread-safe and has to be accompanied by an ugly lock. + # This can cause slow requests to block other requests, so we might need a + # better workaround at some later point. + _haketilo_app_lock: Lock + + _haketilo_blueprints: t.ClassVar[t.Sequence[flask.Blueprint]] + _haketilo_ui_domain: t.ClassVar[_app.UIDomain] + def __init__(self): super().__init__(__name__) + self._haketilo_app_lock = Lock() + self.jinja_options = { **self.jinja_options, 'loader': jinja2.PackageLoader(__package__), @@ -99,19 +111,13 @@ class WebUIAppImpl(_app.WebUIApp): self.before_request(authenticate_by_referrer) - for blueprint in [ - rules.bp, repos.bp, items.bp, items_import.bp, prompts.bp - ]: - self.register_blueprint(blueprint) + for bp in self._haketilo_blueprints: + self.register_blueprint(bp) -# Flask app is not thread-safe and has to be accompanied by an ugly lock. This -# can cause slow requests to block other requests, so we might need a better -# workaround at some later point. -app = WebUIAppImpl() -app_lock = Lock() +home_bp = flask.Blueprint('home', __package__) -@app.route('/', methods=['GET']) +@home_bp.route('/', methods=['GET']) def home(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response: state = _app.get_haketilo_state() @@ -122,7 +128,7 @@ def home(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response: ) return flask.make_response(html, 200) -@app.route('/', methods=['POST']) +@home_bp.route('/', methods=['POST']) def home_post() -> werkzeug.Response: action = flask.request.form['action'] @@ -149,22 +155,57 @@ def home_post() -> werkzeug.Response: return flask.redirect(flask.url_for('.home'), 303) +blueprints_main = \ + (rules.bp, repos.bp, items.bp, items_import.bp, prompts.bp, home_bp) + +@dc.dataclass(init=False) +class AppMain(WebUIAppImpl): + _haketilo_blueprints = blueprints_main + _haketilo_ui_domain = _app.UIDomain.MAIN + + +landing_bp = flask.Blueprint('landing_page', __package__) + +@landing_bp.route('/', methods=['GET']) +def landing(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response: + state = _app.get_haketilo_state() + + html = flask.render_template( + 'landing.html.jinja', + listen_host = state.listen_host, + listen_port = state.listen_port + ) + return flask.make_response(html, 200) + +@dc.dataclass(init=False) +class AppLandingPage(WebUIAppImpl): + _haketilo_blueprints = (landing_bp,) + _haketilo_ui_domain = _app.UIDomain.LANDING_PAGE + + +apps_seq = [AppMain(), AppLandingPage()] +apps = dict((app._haketilo_ui_domain, app) for app in apps_seq) + + def process_request( request_info: http_messages.RequestInfo, - state: st.HaketiloState + state: st.HaketiloState, + ui_domain: _app.UIDomain = _app.UIDomain.MAIN ) -> http_messages.ProducedResponse: path = '/'.join(('', *request_info.url.path_segments)) if (request_info.url.has_trailing_slash): path += '/' - with app_lock: + app = apps[ui_domain] + + with app._haketilo_app_lock: app._haketilo_state = state app.jinja_env.install_gettext_translations(make_translation()) flask_response = app.test_client().open( path = path, - base_url = f'{request_info.url.scheme}://hkt.mitm.it', + base_url = request_info.url.url_without_path, method = request_info.method, query_string = request_info.url.query, headers = [*request_info.headers.items()], diff --git a/src/hydrilla/proxy/web_ui/templates/base.html.jinja b/src/hydrilla/proxy/web_ui/templates/base.html.jinja index e4760bf..f89b39a 100644 --- a/src/hydrilla/proxy/web_ui/templates/base.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/base.html.jinja @@ -17,11 +17,9 @@ You can choose to use either of these licenses or both. I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. --#} +#} <!DOCTYPE html> -{% set settings = get_settings() %} - {% macro button_row(buttons_data, common_fields={}) %} <div class="flex-row"> {% for classes, text, extra_fields in buttons_data %} @@ -223,96 +221,13 @@ code in a proprietary work, I am not going to enforce this in court. .hide { display: none !important; } - - ul#nav { - -moz-user-select: none; - user-select: none; - display: flex; - justify-content: stretch; - white-space: nowrap; - background-color: #e0e0e0; - margin: 0; - padding: 0; - border-bottom: 2px solid #444; - overflow-x: scroll; - } - - li.nav-entry, li.nav-separator { - list-style-type: none; - } - - li.nav-entry { - background-color: #70af70; - font-size: 115%; - cursor: pointer; - text-align: center; - flex: 1 1 0; - } - - li.nav-separator { - flex: 0 0 2px; - background-color: inherit; - } - - li.big-separator { - flex: 4 0 2px; - } - - li.nav-entry:hover { - box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); - } - - ul#nav > li.nav-active { - background-color: #65A065; - color: #222; - box-shadow: none; - cursor: default; - } - - ul#nav > li > :only-child { - display: block; - padding: 10px; - } - {% endblock %} + {% endblock style %} </style> - {% endblock %} + {% endblock head %} </head> <body> - {% set active_endpoint = get_current_endpoint() %} - {% - set navigation_bar = [ - ('home', _('web_ui.base.nav.home'), false), - ('rules.rules', _('web_ui.base.nav.rules'), false), - ('items.packages', _('web_ui.base.nav.packages'), false), - ('items.libraries', _('web_ui.base.nav.libraries'), true), - ('repos.repos', _('web_ui.base.nav.repos'), false), - ('import.items_import', _('web_ui.base.nav.import'), false) - ] - %} - <ul id="nav"> - {% - for endpoint, label, advanced_user_only in navigation_bar - if not advanced_user_only or settings.advanced_user - %} - {% if not loop.first %} - {% set sep_classes = ['nav-separator'] %} - {% if loop.last %} - {% do sep_classes.append('big-separator') %} - {% endif %} - <li class="{{ sep_classes|join(' ') }}"></li> - {% endif %} - - {% if endpoint == active_endpoint %} - <li class="nav-entry nav-active"><div>{{ label }}</div></li> - {% else %} - <li class="nav-entry"> - <a href="{{ url_for(endpoint) }}" draggable="false"> - {{ label }} - </a> - </li> - {% endif %} - {% endfor %} - </ul> - <div id="main">{% block main required %}{% endblock %}</div> + {% block body %} + <div id="main">{% block main required %}{% endblock %}</div> + {% endblock body %} </body> </html> diff --git a/src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja b/src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja new file mode 100644 index 0000000..6a6de99 --- /dev/null +++ b/src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja @@ -0,0 +1,116 @@ +{# +SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0 + +Proxy web UI base page template of htk.mitm.it meta-site. + +This file is part of Hydrilla&Haketilo. + +Copyright (C) 2022 Wojtek Kosior + +Dual licensed under +* GNU General Public License v3.0 or later and +* Creative Commons Attribution Share Alike 4.0 International. + +You can choose to use either of these licenses or both. + + +I, Wojtek Kosior, thereby promise not to sue for violation of this +file's licenses. Although I request that you do not make use of this +code in a proprietary work, I am not going to enforce this in court. +#} +{% extends "base.html.jinja" %} + +{% set settings = get_settings() %} + +{% block style %} + {{ super() }} + ul#nav { + -moz-user-select: none; + user-select: none; + display: flex; + justify-content: stretch; + white-space: nowrap; + background-color: #e0e0e0; + margin: 0; + padding: 0; + border-bottom: 2px solid #444; + overflow-x: scroll; + } + + li.nav-entry, li.nav-separator { + list-style-type: none; + } + + li.nav-entry { + background-color: #70af70; + font-size: 115%; + cursor: pointer; + text-align: center; + flex: 1 1 0; + } + + li.nav-separator { + flex: 0 0 2px; + background-color: inherit; + } + + li.big-separator { + flex: 4 0 2px; + } + + li.nav-entry:hover { + box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); + } + + ul#nav > li.nav-active { + background-color: #65A065; + color: #222; + box-shadow: none; + cursor: default; + } + + ul#nav > li > :only-child { + display: block; + padding: 10px; + } +{% endblock style %} + +{% block body %} + {% set active_endpoint = get_current_endpoint() %} + {% + set navigation_bar = [ + ('home.home', _('web_ui.base.nav.home'), false), + ('rules.rules', _('web_ui.base.nav.rules'), false), + ('items.packages', _('web_ui.base.nav.packages'), false), + ('items.libraries', _('web_ui.base.nav.libraries'), true), + ('repos.repos', _('web_ui.base.nav.repos'), false), + ('import.items_import', _('web_ui.base.nav.import'), false) + ] + %} + <ul id="nav"> + {% + for endpoint, label, advanced_user_only in navigation_bar + if not advanced_user_only or settings.advanced_user + %} + {% if not loop.first %} + {% set sep_classes = ['nav-separator'] %} + {% if loop.last %} + {% do sep_classes.append('big-separator') %} + {% endif %} + <li class="{{ sep_classes|join(' ') }}"></li> + {% endif %} + + {% if endpoint == active_endpoint %} + <li class="nav-entry nav-active"><div>{{ label }}</div></li> + {% else %} + <li class="nav-entry"> + <a href="{{ url_for(endpoint) }}" draggable="false"> + {{ label }} + </a> + </li> + {% endif %} + {% endfor %} + </ul> + + {{ super() }} +{% endblock body %} diff --git a/src/hydrilla/proxy/web_ui/templates/import.html.jinja b/src/hydrilla/proxy/web_ui/templates/import.html.jinja index 7636b77..6ec9947 100644 --- a/src/hydrilla/proxy/web_ui/templates/import.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/import.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.import.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/index.html.jinja b/src/hydrilla/proxy/web_ui/templates/index.html.jinja index e42f5e9..010c2ed 100644 --- a/src/hydrilla/proxy/web_ui/templates/index.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/index.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.home.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja index e517f3b..ccfa6b9 100644 --- a/src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% macro version_with_repo(info) -%} {{ info.info.version_string }} diff --git a/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja index 0a72b64..0996b8b 100644 --- a/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.libraries.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja index bc6b5bb..b79d594 100644 --- a/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.packages.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/landing.html.jinja b/src/hydrilla/proxy/web_ui/templates/landing.html.jinja new file mode 100644 index 0000000..6314cfd --- /dev/null +++ b/src/hydrilla/proxy/web_ui/templates/landing.html.jinja @@ -0,0 +1,49 @@ +{# +SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0 + +Proxy web UI landing page. + +This file is part of Hydrilla&Haketilo. + +Copyright (C) 2022 Wojtek Kosior + +Dual licensed under +* GNU General Public License v3.0 or later and +* Creative Commons Attribution Share Alike 4.0 International. + +You can choose to use either of these licenses or both. + + +I, Wojtek Kosior, thereby promise not to sue for violation of this +file's licenses. Although I request that you do not make use of this +code in a proprietary work, I am not going to enforce this in court. +#} +{% extends "base.html.jinja" %} + +{% block title %} {{ _('web_ui.landing.title') }} {% endblock %} + +{% block main %} + <h3> + {{ _('web_ui.landing.heading.haketilo_is_running') }} + </h3> + + <p> + {{ _('web_ui.landing.web_ui.landing.what_to_do_1') }} + </p> + + {{ label(_('web_ui.landing.host_label')) }} + + <p> + {{ listen_host }} + </p> + + {{ label(_('web_ui.landing.port_label')) }} + + <p> + {{ listen_port }} + </p> + + <p class="has-colored-links"> + {{ _('web_ui.landing.html.what_to_do_2')|safe }} + </p> +{% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja b/src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja index f4f600c..a17e61d 100644 --- a/src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.prompts.auto_install_error.title') }} diff --git a/src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja b/src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja index ba06ae9..2df38b3 100644 --- a/src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja @@ -19,7 +19,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.prompts.package_suggestion.title') }} diff --git a/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja b/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja index f635444..106af53 100644 --- a/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.repos.add.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja b/src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja index e670b59..4f09ae6 100644 --- a/src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %}{{ _('web_ui.repos.title') }}{% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja b/src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja index 04075c4..c4b7a9a 100644 --- a/src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.repos.single.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja b/src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja index 430c5ca..6d21ccd 100644 --- a/src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.rules.add.title') }} {% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja b/src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja index 799eaba..57cc8ad 100644 --- a/src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %}{{ _('web_ui.rules.title') }}{% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja b/src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja index 9bf2c75..95e8009 100644 --- a/src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja @@ -18,7 +18,7 @@ I, Wojtek Kosior, thereby promise not to sue for violation of this file's licenses. Although I request that you do not make use of this code in a proprietary work, I am not going to enforce this in court. #} -{% extends "base.html.jinja" %} +{% extends "hkt_mitm_it_base.html.jinja" %} {% block title %} {{ _('web_ui.rules.single.title') }} {% endblock %} |