diff options
Diffstat (limited to 'src/hydrilla/proxy/web_ui/root.py')
-rw-r--r-- | src/hydrilla/proxy/web_ui/root.py | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/src/hydrilla/proxy/web_ui/root.py b/src/hydrilla/proxy/web_ui/root.py new file mode 100644 index 0000000..9a14268 --- /dev/null +++ b/src/hydrilla/proxy/web_ui/root.py @@ -0,0 +1,303 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +# Proxy web UI root. +# +# This file is part of Hydrilla&Haketilo. +# +# Copyright (C) 2022 Wojtek Kosior +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# +# I, Wojtek Kosior, thereby promise not to sue for violation of this +# file's license. Although I request that you do not make use of this +# code in a proprietary program, I am not going to enforce this in +# court. + +""" +This module instantiated Flask apps responsible for the web UI and facilitates +conversion of Flask response objects to the ResponseInfo type used by other +Haketilo code. + +In addition, the Haketilo root/settings page and landing page also have their +handlers defined here. +""" + +import re +import dataclasses as dc +import typing as t + +from threading import Lock +from urllib.parse import urlparse + +import jinja2 +import flask +import werkzeug + +from ... import translations +from ... import versions +from ... import item_infos +from ... import common_jinja_templates +from .. import state as st +from .. import http_messages +from .. import self_doc +from . import rules +from . import repos +from . import items +from . import items_import +from . import prompts +from . import _app + + +def choose_locale() -> None: + app = t.cast(WebUIAppImpl, flask.current_app) + + user_chosen_locale = get_settings().locale + if user_chosen_locale not in translations.supported_locales: + user_chosen_locale = None + + if user_chosen_locale is None: + best_locale_match = flask.request.accept_languages.best_match( + translations.supported_locales, + default = translations.default_locale + ) + if best_locale_match is None: + app._haketilo_request_locale = translations.default_locale + else: + app._haketilo_request_locale = best_locale_match + else: + app._haketilo_request_locale = user_chosen_locale + + trans = translations.translation(app._haketilo_request_locale) + + app.jinja_env.install_gettext_translations(trans) + + +def authenticate_by_referrer() -> t.Optional[werkzeug.Response]: + if flask.request.method == 'GET': + return None + + parsed_url = urlparse(flask.request.referrer) + if parsed_url.netloc == 'hkt.mitm.it': + return None + + flask.abort(403) + + +def get_current_endpoint() -> str: + endpoint = flask.request.endpoint + assert endpoint is not None + return endpoint + +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] + + _haketilo_request_locale: str + + def __init__(self): + super().__init__(__name__) + + self._haketilo_app_lock = Lock() + + loaders = [jinja2.PackageLoader(__package__), self_doc.loader] + combined_loader = common_jinja_templates.combine_with_loaders(loaders) + + self.jinja_options = { + **self.jinja_options, + 'loader': combined_loader, + 'autoescape': jinja2.select_autoescape(['.jinja']), + 'lstrip_blocks': True, + 'extensions': [ + *self.jinja_options.get('extensions', []), + 'jinja2.ext.i18n', + 'jinja2.ext.do' + ] + } + + self.jinja_env.globals['get_current_endpoint'] = get_current_endpoint + self.jinja_env.globals['get_settings'] = get_settings + self.jinja_env.globals['EnabledStatus'] = st.EnabledStatus + self.jinja_env.globals['FrozenStatus'] = st.FrozenStatus + self.jinja_env.globals['InstalledStatus'] = st.InstalledStatus + self.jinja_env.globals['ActiveStatus'] = st.ActiveStatus + self.jinja_env.globals['ItemType'] = item_infos.ItemType + self.jinja_env.globals['MappingUseMode'] = st.MappingUseMode + self.jinja_env.globals['versions'] = versions + self.jinja_env.globals['doc_base_filename'] = 'doc_base.html.jinja' + + self.before_request(authenticate_by_referrer) + self.before_request(choose_locale) + + for bp in self._haketilo_blueprints: + self.register_blueprint(bp) + + +home_bp = flask.Blueprint('home', __package__) + +@home_bp.route('/', methods=['GET']) +def home(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response: + state = _app.get_haketilo_state() + + html = flask.render_template( + 'index.html.jinja', + orphan_item_stats = state.count_orphan_items(), + **errors + ) + return flask.make_response(html, 200) + +popup_toggle_action_re = re.compile( + r'^popup_(yes|no)_when_(jsallowed|jsblocked|payloadon)$' +) + +@home_bp.route('/', methods=['POST']) +def home_post() -> werkzeug.Response: + action = flask.request.form['action'] + + state = _app.get_haketilo_state() + + if action == 'set_lang': + new_locale = flask.request.form['locale'] + assert new_locale in translations.supported_locales + state.update_settings(locale=new_locale) + elif action == 'use_enabled': + state.update_settings(mapping_use_mode=st.MappingUseMode.WHEN_ENABLED) + elif action == 'use_auto': + state.update_settings(mapping_use_mode=st.MappingUseMode.AUTO) + elif action == 'use_question': + state.update_settings(mapping_use_mode=st.MappingUseMode.QUESTION) + elif action == 'allow_scripts': + state.update_settings(default_allow_scripts=True) + elif action == 'block_scripts': + state.update_settings(default_allow_scripts=False) + elif action == 'user_make_advanced': + state.update_settings(advanced_user=True) + elif action == 'user_make_simple': + state.update_settings(advanced_user=False) + elif action == 'upate_all_items': + try: + state.upate_all_items() + except st.FileInstallationError: + return home({'file_installation_error': True}) + except st.ImpossibleSituation: + return home({'impossible_situation_error': True}) + elif action == 'prune_orphans': + state.prune_orphan_items() + else: + match = popup_toggle_action_re.match(action) + if match is None: + raise ValueError() + + popup_enable = match.group(1) == 'yes' + page_type = match.group(2) + + settings_prop = f'default_popup_{page_type}' + old_settings = getattr(state.get_settings(), settings_prop) + + new_settings = dc.replace(old_settings, keyboard_trigger=popup_enable) + + state.update_settings(default_popup_settings={page_type: new_settings}) + + return flask.redirect(flask.url_for('.home'), 303) + +@home_bp.route('/doc/<path:page>', methods=['GET']) +def home_doc(page: str) -> str: + if page not in self_doc.page_names: + flask.abort(404) + + locale = t.cast(WebUIAppImpl, flask.current_app)._haketilo_request_locale + if locale not in self_doc.available_locales: + locale = translations.default_locale + + return flask.render_template( + f'{locale}/{page}.html.jinja', + doc_output = 'html_hkt_mitm_it' + ) + +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, + ui_domain: _app.UIDomain = _app.UIDomain.MAIN +) -> http_messages.ResponseInfo: + path = '/'.join(('', *request_info.url.path_segments)) + if (request_info.url.has_trailing_slash): + path += '/' + + app = apps[ui_domain] + + with app._haketilo_app_lock: + app._haketilo_state = state + + flask_response = app.test_client().open( + path = path, + base_url = request_info.url.url_without_path, + method = request_info.method, + query_string = request_info.url.query, + headers = [*request_info.headers.items()], + data = request_info.body + ) + + headers_bytes = [ + (key.encode(), val.encode()) + for key, val + in flask_response.headers + ] + + return http_messages.ResponseInfo.make( + status_code = flask_response.status_code, + headers = headers_bytes, + body = flask_response.data + ) |