aboutsummaryrefslogtreecommitdiff
path: root/src/hydrilla/proxy/web_ui/root.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/hydrilla/proxy/web_ui/root.py')
-rw-r--r--src/hydrilla/proxy/web_ui/root.py303
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
+ )