aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/hydrilla/__init__.py10
-rw-r--r--src/hydrilla/builder/__init__.py7
-rw-r--r--src/hydrilla/builder/__main__.py9
-rw-r--r--src/hydrilla/builder/build.py510
-rw-r--r--src/hydrilla/builder/common_errors.py63
-rw-r--r--src/hydrilla/builder/local_apt.py448
-rw-r--r--src/hydrilla/builder/piggybacking.py122
m---------src/hydrilla/common_jinja_templates0
-rw-r--r--src/hydrilla/exceptions.py38
-rw-r--r--src/hydrilla/item_infos.py699
-rw-r--r--src/hydrilla/json_instances.py221
-rw-r--r--src/hydrilla/locales/en_US/LC_MESSAGES/messages.po1511
-rw-r--r--src/hydrilla/locales/pl_PL/LC_MESSAGES/messages.po1541
-rw-r--r--src/hydrilla/mitmproxy_launcher/__init__.py5
-rw-r--r--src/hydrilla/mitmproxy_launcher/__main__.py11
-rw-r--r--src/hydrilla/mitmproxy_launcher/addon_script.py.mitmproxy9
-rw-r--r--src/hydrilla/mitmproxy_launcher/launch.py104
-rw-r--r--src/hydrilla/pattern_tree.py311
-rw-r--r--src/hydrilla/proxy/__init__.py5
-rw-r--r--src/hydrilla/proxy/addon.py379
-rw-r--r--src/hydrilla/proxy/csp.py196
-rw-r--r--src/hydrilla/proxy/http_messages.py244
-rw-r--r--src/hydrilla/proxy/policies/__init__.py18
-rw-r--r--src/hydrilla/proxy/policies/base.py363
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja97
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja22
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/js_fallback_allowed_info.html.jinja14
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/js_fallback_blocked_info.html.jinja15
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/js_rule_allowed_info.html.jinja14
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/js_rule_blocked_info.html.jinja15
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/js_rule_info.html.jinja39
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja50
-rw-r--r--src/hydrilla/proxy/policies/info_pages_templates/special_page_info.html.jinja17
-rw-r--r--src/hydrilla/proxy/policies/injectable_scripts/page_init_script.js.jinja151
-rw-r--r--src/hydrilla/proxy/policies/injectable_scripts/popup.js.jinja221
-rw-r--r--src/hydrilla/proxy/policies/misc.py110
-rw-r--r--src/hydrilla/proxy/policies/payload.py271
-rw-r--r--src/hydrilla/proxy/policies/payload_resource.py398
-rw-r--r--src/hydrilla/proxy/policies/rule.py122
-rw-r--r--src/hydrilla/proxy/policies/web_ui.py74
-rw-r--r--src/hydrilla/proxy/self_doc.py27
-rw-r--r--src/hydrilla/proxy/self_doc/doc_base.html.jinja75
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/advanced_ui_features.html.jinja70
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/doc_index.html.jinja59
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/packages.html.jinja218
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/policy_selection.html.jinja109
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/popup.html.jinja157
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/repositories.html.jinja128
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/script_blocking.html.jinja125
-rw-r--r--src/hydrilla/proxy/self_doc/en_US/url_patterns.html.jinja409
-rw-r--r--src/hydrilla/proxy/simple_dependency_satisfying.py343
-rw-r--r--src/hydrilla/proxy/state.py658
-rw-r--r--src/hydrilla/proxy/state_impl/__init__.py7
-rw-r--r--src/hydrilla/proxy/state_impl/_operations/__init__.py10
-rw-r--r--src/hydrilla/proxy/state_impl/_operations/load_packages.py410
-rw-r--r--src/hydrilla/proxy/state_impl/_operations/prune_orphans.py182
-rw-r--r--src/hydrilla/proxy/state_impl/_operations/pull_missing_files.py110
-rw-r--r--src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py461
-rw-r--r--src/hydrilla/proxy/state_impl/base.py280
-rw-r--r--src/hydrilla/proxy/state_impl/concrete_state.py523
-rw-r--r--src/hydrilla/proxy/state_impl/items.py811
-rw-r--r--src/hydrilla/proxy/state_impl/payloads.py272
-rw-r--r--src/hydrilla/proxy/state_impl/repos.py363
-rw-r--r--src/hydrilla/proxy/state_impl/rules.py196
-rw-r--r--src/hydrilla/proxy/state_impl/tables.sql334
-rw-r--r--src/hydrilla/proxy/web_ui/__init__.py8
-rw-r--r--src/hydrilla/proxy/web_ui/_app.py29
-rw-r--r--src/hydrilla/proxy/web_ui/items.py440
-rw-r--r--src/hydrilla/proxy/web_ui/items_import.py198
-rw-r--r--src/hydrilla/proxy/web_ui/prompts.py181
-rw-r--r--src/hydrilla/proxy/web_ui/repos.py137
-rw-r--r--src/hydrilla/proxy/web_ui/root.py303
-rw-r--r--src/hydrilla/proxy/web_ui/rules.py122
-rw-r--r--src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja121
-rw-r--r--src/hydrilla/proxy/web_ui/templates/import.html.jinja125
-rw-r--r--src/hydrilla/proxy/web_ui/templates/index.html.jinja365
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja112
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja209
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja55
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja38
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja103
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja127
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja252
-rw-r--r--src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja83
-rw-r--r--src/hydrilla/proxy/web_ui/templates/landing.html.jinja49
-rw-r--r--src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja57
-rw-r--r--src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja58
-rw-r--r--src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja53
-rw-r--r--src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja90
-rw-r--r--src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja183
-rw-r--r--src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja60
-rw-r--r--src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja64
-rw-r--r--src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja106
-rw-r--r--src/hydrilla/proxy/web_ui/templates/web_ui_base.html.jinja22
-rw-r--r--src/hydrilla/py.typed5
m---------src/hydrilla/schemas/1.x0
m---------src/hydrilla/schemas/2.x0
-rw-r--r--src/hydrilla/server/config.json3
-rw-r--r--src/hydrilla/server/config.py43
-rw-r--r--src/hydrilla/server/locales/en_US/LC_MESSAGES/hydrilla-messages.po147
-rw-r--r--src/hydrilla/server/malcontent.py252
-rw-r--r--src/hydrilla/server/serve.py560
-rw-r--r--src/hydrilla/server/templates/base.html5
-rw-r--r--src/hydrilla/server/templates/index.html5
-rw-r--r--src/hydrilla/translations.py107
-rw-r--r--src/hydrilla/url_patterns.py237
-rw-r--r--src/hydrilla/versions.py78
107 files changed, 19341 insertions, 642 deletions
diff --git a/src/hydrilla/__init__.py b/src/hydrilla/__init__.py
index 6aeb276..d382ead 100644
--- a/src/hydrilla/__init__.py
+++ b/src/hydrilla/__init__.py
@@ -1,7 +1,5 @@
-# SPDX-License-Identifier: 0BSD
+# SPDX-License-Identifier: CC0-1.0
-# Copyright (C) 2013-2020, PyPA
-
-# https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
-
-__path__ = __import__('pkgutil').extend_path(__path__, __name__)
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
diff --git a/src/hydrilla/builder/__init__.py b/src/hydrilla/builder/__init__.py
new file mode 100644
index 0000000..73dc579
--- /dev/null
+++ b/src/hydrilla/builder/__init__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+from .build import Build
diff --git a/src/hydrilla/builder/__main__.py b/src/hydrilla/builder/__main__.py
new file mode 100644
index 0000000..87dc9e2
--- /dev/null
+++ b/src/hydrilla/builder/__main__.py
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+from . import build
+
+build.perform()
diff --git a/src/hydrilla/builder/build.py b/src/hydrilla/builder/build.py
new file mode 100644
index 0000000..3ae6ea9
--- /dev/null
+++ b/src/hydrilla/builder/build.py
@@ -0,0 +1,510 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Building Hydrilla packages.
+#
+# This file is part of Hydrilla
+#
+# 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.
+
+import json
+import re
+import zipfile
+import subprocess
+import typing as t
+
+from pathlib import Path, PurePosixPath
+from hashlib import sha256
+from sys import stderr
+from contextlib import contextmanager
+from tempfile import TemporaryDirectory, TemporaryFile
+
+import jsonschema # type: ignore
+import click
+
+from .. import _version, json_instances, versions
+from ..translations import smart_gettext as _
+from . import local_apt
+from .piggybacking import Piggybacked
+from .common_errors import *
+
+here = Path(__file__).resolve().parent
+
+schemas_root = 'https://hydrilla.koszko.org/schemas'
+
+generated_by = {
+ 'name': 'hydrilla.builder',
+ 'version': _version.version
+}
+
+class ReuseError(SubprocessError):
+ """
+ Exception used to report various problems when calling the REUSE tool.
+ """
+
+def generate_spdx_report(root: Path) -> bytes:
+ """
+ Use REUSE tool to generate an SPDX report for sources under 'root' and
+ return the report's contents as 'bytes'.
+
+ In case the directory tree under 'root' does not constitute a
+ REUSE-compliant package, as exception is raised with linting report
+ included in it.
+
+ In case the reuse tool is not installed, an exception is also raised.
+ """
+ for command in [
+ ['reuse', '--root', str(root), 'lint'],
+ ['reuse', '--root', str(root), 'spdx']
+ ]:
+ try:
+ cp = subprocess.run(command, capture_output=True, text=True)
+ except FileNotFoundError:
+ msg = _('couldnt_execute_{}_is_it_installed').format('reuse')
+ raise ReuseError(msg)
+
+ if cp.returncode != 0:
+ msg = _('command_{}_failed').format(' '.join(command))
+ raise ReuseError(msg, cp)
+
+ return cp.stdout.encode()
+
+class FileRef:
+ """Represent reference to a file in the package."""
+ def __init__(self, path: PurePosixPath, contents: bytes) -> None:
+ """Initialize FileRef."""
+ self.include_in_distribution = False
+ self.include_in_source_archive = True
+ self.path = path
+ self.contents = contents
+
+ self.contents_hash = sha256(contents).digest().hex()
+
+ def make_ref_dict(self) -> t.Dict[str, str]:
+ """
+ Represent the file reference through a dict that can be included in JSON
+ defintions.
+ """
+ return {
+ 'file': str(self.path),
+ 'sha256': self.contents_hash
+ }
+
+@contextmanager
+def piggybacked_system(
+ piggyback_def: t.Optional[dict],
+ piggyback_files: t.Optional[Path]
+)-> t.Iterator[Piggybacked]:
+ """
+ Resolve resources from a foreign software packaging system. Optionally, use
+ package files (.deb's, etc.) from a specified directory instead of resolving
+ and downloading them.
+ """
+ if piggyback_def is None:
+ yield Piggybacked()
+ else:
+ # apt is the only supported system right now
+ assert piggyback_def['system'] == 'apt'
+
+ with local_apt.piggybacked_system(piggyback_def, piggyback_files) \
+ as piggybacked:
+ yield piggybacked
+
+class Build:
+ """
+ Build a Hydrilla package.
+ """
+ def __init__(
+ self,
+ srcdir: Path,
+ index_json_path: Path,
+ piggyback_files: t.Optional[Path] = None
+ ) -> None:
+ """
+ Initialize a build. All files to be included in a distribution package
+ are loaded into memory, all data gets validated and all necessary
+ computations (e.g. preparing of hashes) are performed.
+ """
+ self.srcdir = srcdir.resolve()
+ self.piggyback_files = piggyback_files
+ if piggyback_files is None:
+ piggyback_default_path = \
+ srcdir.parent / f'{srcdir.name}.foreign-packages'
+ if piggyback_default_path.exists():
+ self.piggyback_files = piggyback_default_path
+
+ self.files_by_path: t.Dict[PurePosixPath, FileRef] = {}
+ self.resource_list: t.List[dict] = []
+ self.mapping_list: t.List[dict] = []
+
+ if not index_json_path.is_absolute():
+ index_json_path = (self.srcdir / index_json_path)
+
+ index_obj = json_instances.read_instance(index_json_path)
+ schema_fmt = 'package_source-{}.schema.json'
+ json_instances.validate_instance(index_obj, schema_fmt)
+
+ index_desired_path = PurePosixPath('index.json')
+ self.files_by_path[index_desired_path] = \
+ FileRef(index_desired_path, index_json_path.read_bytes())
+
+ # We know from successful validation that instance is a dict.
+ self._process_index_json(t.cast('t.Dict[str, t.Any]', index_obj))
+
+ def _process_file(
+ self,
+ filename: t.Union[str, PurePosixPath],
+ piggybacked: Piggybacked,
+ include_in_distribution: bool = True
+ ) -> t.Dict[str, str]:
+ """
+ Resolve 'filename' relative to srcdir, load it to memory (if not loaded
+ before), compute its hash and store its information in
+ 'self.files_by_path'.
+
+ 'filename' shall represent a relative path withing package directory.
+
+ if 'include_in_distribution' is True it shall cause the file to not only
+ be included in the source package's zipfile, but also written as one of
+ built package's files.
+
+ For each file an attempt is made to resolve it using 'piggybacked'
+ object. If a file is found and pulled from foreign software packaging
+ system this way, it gets automatically excluded from inclusion in
+ Hydrilla source package's zipfile.
+
+ Return value is file's reference object that can be included in JSON
+ defintions of various kinds.
+ """
+ include_in_source_archive = True
+
+ desired_path = PurePosixPath(filename)
+ if '..' in desired_path.parts:
+ msg = _('path_contains_double_dot_{}').format(filename)
+ raise FileReferenceError(msg)
+
+ path = piggybacked.resolve_file(desired_path)
+ if path is None:
+ path = (self.srcdir / desired_path).resolve()
+ try:
+ path.relative_to(self.srcdir)
+ except ValueError:
+ raise FileReferenceError(_('loading_{}_outside_package_dir')
+ .format(filename))
+
+ if str(path.relative_to(self.srcdir)) == 'index.json':
+ raise FileReferenceError(_('loading_reserved_index_json'))
+ else:
+ include_in_source_archive = False
+
+ file_ref = self.files_by_path.get(desired_path)
+ if file_ref is None:
+ if not path.is_file():
+ msg = _('referenced_file_{}_missing').format(desired_path)
+ raise FileReferenceError(msg)
+
+ file_ref = FileRef(desired_path, path.read_bytes())
+ self.files_by_path[desired_path] = file_ref
+
+ if include_in_distribution:
+ file_ref.include_in_distribution = True
+
+ if not include_in_source_archive:
+ file_ref.include_in_source_archive = False
+
+ return file_ref.make_ref_dict()
+
+ def _prepare_source_package_zip(
+ self,
+ source_name: str,
+ piggybacked: Piggybacked
+ ) -> str:
+ """
+ Create and store in memory a .zip archive containing files needed to
+ build this source package.
+
+ 'src_dir_name' shall not contain any slashes ('/').
+
+ Return zipfile's sha256 sum's hexstring.
+ """
+ tf = TemporaryFile()
+ source_dir_path = PurePosixPath(source_name)
+ piggybacked_dir_path = PurePosixPath(f'{source_name}.foreign-packages')
+
+ with zipfile.ZipFile(tf, 'w') as zf:
+ for file_ref in self.files_by_path.values():
+ if file_ref.include_in_source_archive:
+ zf.writestr(str(source_dir_path / file_ref.path),
+ file_ref.contents)
+
+ for desired_path, real_path in piggybacked.archive_files():
+ zf.writestr(str(piggybacked_dir_path / desired_path),
+ real_path.read_bytes())
+
+ tf.seek(0)
+ self.source_zip_contents = tf.read()
+
+ return sha256(self.source_zip_contents).digest().hex()
+
+ def _process_item(
+ self,
+ as_what: str,
+ item_def: dict,
+ piggybacked: Piggybacked
+ ) -> t.Dict[str, t.Any]:
+ """
+ Process 'item_def' as definition of a resource or mapping (determined by
+ 'as_what' param) and store in memory its processed form and files used
+ by it.
+
+ Return a minimal item reference suitable for using in source
+ description.
+ """
+ resulting_schema_version = versions.normalize([1])
+
+ copy_props = ['identifier', 'long_name', 'description',
+ *filter(lambda p: p in item_def, ('comment', 'uuid'))]
+
+ new_item_obj: dict = {}
+
+ if as_what == 'resource':
+ item_list = self.resource_list
+
+ copy_props.append('revision')
+
+ script_file_refs = [self._process_file(f['file'], piggybacked)
+ for f in item_def.get('scripts', [])]
+
+ deps = [{'identifier': res_ref['identifier']}
+ for res_ref in item_def.get('dependencies', [])]
+
+ new_item_obj['dependencies'] = \
+ [*piggybacked.resource_must_depend, *deps]
+ new_item_obj['scripts'] = script_file_refs
+ else:
+ item_list = self.mapping_list
+
+ payloads = {}
+ for pat, res_ref in item_def.get('payloads', {}).items():
+ payloads[pat] = {'identifier': res_ref['identifier']}
+
+ new_item_obj['payloads'] = payloads
+
+ version = [*item_def['version']]
+
+ if as_what == 'mapping' and item_def['type'] == "mapping_and_resource":
+ version.append(item_def['revision'])
+
+ new_item_obj['version'] = versions.normalize(version)
+
+ if self.source_schema_ver >= (2,):
+ # handle 'required_mappings' field
+ required = [{'identifier': map_ref['identifier']}
+ for map_ref in item_def.get('required_mappings', [])]
+ if required:
+ resulting_schema_version = max(
+ resulting_schema_version,
+ versions.normalize([2])
+ )
+ new_item_obj['required_mappings'] = required
+
+ # handle 'permissions' field
+ permissions = item_def.get('permissions', {})
+ processed_permissions = {}
+
+ if permissions.get('cors_bypass'):
+ processed_permissions['cors_bypass'] = True
+ if permissions.get('eval'):
+ processed_permissions['eval'] = True
+
+ if processed_permissions:
+ new_item_obj['permissions'] = processed_permissions
+ resulting_schema_version = max(
+ resulting_schema_version,
+ versions.normalize([2])
+ )
+
+ # handle '{min,max}_haketilo_version' fields
+ for minmax, default in ('min', [1]), ('max', [65536]):
+ constraint = item_def.get(f'{minmax}_haketilo_version')
+ if constraint in (None, default):
+ continue
+
+ copy_props.append(f'{minmax}_haketilo_version')
+ resulting_schema_version = max(
+ resulting_schema_version,
+ versions.normalize([2])
+ )
+
+ new_item_obj.update((p, item_def[p]) for p in copy_props)
+
+ new_item_obj['$schema'] = ''.join([
+ schemas_root,
+ f'/api_{as_what}_description',
+ '-',
+ versions.version_string(resulting_schema_version),
+ '.schema.json'
+ ])
+ new_item_obj['type'] = as_what
+ new_item_obj['source_copyright'] = self.copyright_file_refs
+ new_item_obj['source_name'] = self.source_name
+ new_item_obj['generated_by'] = generated_by
+
+ item_list.append(new_item_obj)
+
+ props_in_ref = ('type', 'identifier', 'version', 'long_name')
+ return dict([(prop, new_item_obj[prop]) for prop in props_in_ref])
+
+ def _process_index_json(self, index_obj: dict) -> None:
+ """
+ Process 'index_obj' as contents of source package's index.json and store
+ in memory this source package's zipfile as well as package's individual
+ files and computed definitions of the source package and items defined
+ in it.
+ """
+ self.source_schema_ver = json_instances.get_schema_version(index_obj)
+
+ out_schema = f'{schemas_root}/api_source_description-1.schema.json'
+
+ self.source_name = index_obj['source_name']
+
+ generate_spdx = index_obj.get('reuse_generate_spdx_report', False)
+ if generate_spdx:
+ contents = generate_spdx_report(self.srcdir)
+ spdx_path = PurePosixPath('report.spdx')
+ spdx_ref = FileRef(spdx_path, contents)
+
+ spdx_ref.include_in_source_archive = False
+ self.files_by_path[spdx_path] = spdx_ref
+
+ piggyback_def = None
+ if self.source_schema_ver >= (2,) and 'piggyback_on' in index_obj:
+ piggyback_def = index_obj['piggyback_on']
+
+ with piggybacked_system(piggyback_def, self.piggyback_files) \
+ as piggybacked:
+ copyright_to_process = [
+ *(file_ref['file'] for file_ref in index_obj['copyright']),
+ *piggybacked.package_license_files
+ ]
+ self.copyright_file_refs = [self._process_file(f, piggybacked)
+ for f in copyright_to_process]
+
+ if generate_spdx and not spdx_ref.include_in_distribution:
+ raise FileReferenceError(_('report_spdx_not_in_copyright_list'))
+
+ item_refs = []
+ for item_def in index_obj['definitions']:
+ if 'mapping' in item_def['type']:
+ ref = self._process_item('mapping', item_def, piggybacked)
+ item_refs.append(ref)
+ if 'resource' in item_def['type']:
+ ref = self._process_item('resource', item_def, piggybacked)
+ item_refs.append(ref)
+
+ for file_ref in index_obj.get('additional_files', []):
+ self._process_file(file_ref['file'], piggybacked,
+ include_in_distribution=False)
+
+ zipfile_sha256 = self._prepare_source_package_zip\
+ (self.source_name, piggybacked)
+
+ source_archives_obj = {'zip' : {'sha256': zipfile_sha256}}
+
+ self.source_description = {
+ '$schema': out_schema,
+ 'source_name': self.source_name,
+ 'source_copyright': self.copyright_file_refs,
+ 'upstream_url': index_obj['upstream_url'],
+ 'definitions': item_refs,
+ 'source_archives': source_archives_obj,
+ 'generated_by': generated_by
+ }
+
+ if 'comment' in index_obj:
+ self.source_description['comment'] = index_obj['comment']
+
+ def write_source_package_zip(self, dstpath: Path) -> None:
+ """
+ Create a .zip archive containing files needed to build this source
+ package and write it at 'dstpath'.
+ """
+ with open(dstpath, 'wb') as output:
+ output.write(self.source_zip_contents)
+
+ def write_package_files(self, dstpath: Path) -> None:
+ """Write package files under 'dstpath' for distribution."""
+ file_dir_path = (dstpath / 'file' / 'sha256').resolve()
+ file_dir_path.mkdir(parents=True, exist_ok=True)
+
+ for file_ref in self.files_by_path.values():
+ if file_ref.include_in_distribution:
+ file_path = file_dir_path / file_ref.contents_hash
+ file_path.write_bytes(file_ref.contents)
+
+ source_dir_path = (dstpath / 'source').resolve()
+ source_dir_path.mkdir(parents=True, exist_ok=True)
+ source_name = self.source_description["source_name"]
+
+ with open(source_dir_path / f'{source_name}.json', 'wt') as out_str:
+ json.dump(self.source_description, out_str)
+
+ with open(source_dir_path / f'{source_name}.zip', 'wb') as out_bin:
+ out_bin.write(self.source_zip_contents)
+
+ for item_type, item_list in [
+ ('resource', self.resource_list),
+ ('mapping', self.mapping_list)
+ ]:
+ item_type_dir_path = (dstpath / item_type).resolve()
+
+ for item_def in item_list:
+ item_dir_path = item_type_dir_path / item_def['identifier']
+ item_dir_path.mkdir(parents=True, exist_ok=True)
+
+ version = '.'.join([str(n) for n in item_def['version']])
+ with open(item_dir_path / version, 'wt') as output:
+ json.dump(item_def, output)
+
+dir_type = click.Path(exists=True, file_okay=False, resolve_path=True)
+
+@click.command(help=_('build_package_from_srcdir_to_dstdir'))
+@click.option('-s', '--srcdir', default='./', type=dir_type, show_default=True,
+ help=_('source_directory_to_build_from'))
+@click.option('-i', '--index-json', default='index.json', type=click.Path(),
+ help=_('path_instead_of_index_json'))
+@click.option('-p', '--piggyback-files', type=click.Path(),
+ help=_('path_instead_for_piggyback_files'))
+@click.option('-d', '--dstdir', type=dir_type, required=True,
+ help=_('built_package_files_destination'))
+@click.version_option(version=_version.version, prog_name='Hydrilla builder',
+ message=_('%(prog)s_%(version)s_license'),
+ help=_('version_printing'))
+def perform(srcdir, index_json, piggyback_files, dstdir) -> None:
+ """
+ Execute Hydrilla builder to turn source package into a distributable one.
+
+ This command is meant to be the entry point of hydrilla-builder command
+ exported by this package.
+ """
+ build = Build(Path(srcdir), Path(index_json),
+ piggyback_files and Path(piggyback_files))
+ build.write_package_files(Path(dstdir))
diff --git a/src/hydrilla/builder/common_errors.py b/src/hydrilla/builder/common_errors.py
new file mode 100644
index 0000000..c5d131f
--- /dev/null
+++ b/src/hydrilla/builder/common_errors.py
@@ -0,0 +1,63 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Error classes.
+#
+# This file is part of Hydrilla
+#
+# 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 defines error types for use in other parts of Hydrilla builder.
+"""
+
+from pathlib import Path
+from typing import Optional
+from subprocess import CompletedProcess as CP
+
+from ..translations import smart_gettext as _
+
+class DistroError(Exception):
+ """
+ Exception used to report problems when resolving an OS distribution.
+ """
+
+class FileReferenceError(Exception):
+ """
+ Exception used to report various problems concerning files referenced from
+ source package.
+ """
+
+class SubprocessError(Exception):
+ """
+ Exception used to report problems related to execution of external
+ processes, includes. various problems when calling apt-* and dpkg-*
+ commands.
+ """
+ def __init__(self, msg: str, cp: Optional[CP]=None) -> None:
+ """Initialize this SubprocessError"""
+ if cp and cp.stdout:
+ msg = '\n\n'.join([msg, _('STDOUT_OUTPUT_heading'), cp.stdout])
+
+ if cp and cp.stderr:
+ msg = '\n\n'.join([msg, _('STDERR_OUTPUT_heading'), cp.stderr])
+
+ super().__init__(msg)
diff --git a/src/hydrilla/builder/local_apt.py b/src/hydrilla/builder/local_apt.py
new file mode 100644
index 0000000..cc28bcc
--- /dev/null
+++ b/src/hydrilla/builder/local_apt.py
@@ -0,0 +1,448 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Using a local APT.
+#
+# This file is part of Hydrilla
+#
+# 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.
+
+import zipfile
+import shutil
+import re
+import subprocess
+CP = subprocess.CompletedProcess
+import typing as t
+
+from pathlib import Path, PurePosixPath
+from tempfile import TemporaryDirectory, NamedTemporaryFile
+from hashlib import sha256
+from urllib.parse import unquote
+from contextlib import contextmanager
+
+from ..translations import smart_gettext as _
+from .piggybacking import Piggybacked
+from .common_errors import *
+
+here = Path(__file__).resolve().parent
+
+"""
+Default cache directory to save APT configurations and downloaded GPG keys in.
+"""
+default_apt_cache_dir = Path.home() / '.cache' / 'hydrilla' / 'builder' / 'apt'
+
+"""
+Default keyserver to use.
+"""
+default_keyserver = 'hkps://keyserver.ubuntu.com:443'
+
+"""
+Default keys to download when using a local APT.
+"""
+default_keys = [
+ # Trisquel
+ 'E6C27099CA21965B734AEA31B4EFB9F38D8AEBF1',
+ '60364C9869F92450421F0C22B138CA450C05112F',
+ # Ubuntu
+ '630239CC130E1A7FD81A27B140976EAF437D05B5',
+ '790BC7277767219C42C86F933B4FE6ACC0B21F32',
+ 'F6ECB3762474EDA9D21B7022871920D1991BC93C',
+ # Debian
+ '6D33866EDD8FFA41C0143AEDDCC9EFBF77E11517',
+ '80D15823B7FD1561F9F7BCDDDC30D7C23CBBABEE',
+ 'AC530D520F2F3269F5E98313A48449044AAD5C5D'
+]
+
+"""sources.list file contents for known distros."""
+default_lists = {
+ 'nabia': [f'{type} http://archive.trisquel.info/trisquel/ nabia{suf} main'
+ for type in ('deb', 'deb-src')
+ for suf in ('', '-updates', '-security')]
+}
+
+class GpgError(Exception):
+ """
+ Exception used to report various problems when calling GPG.
+ """
+
+class AptError(SubprocessError):
+ """
+ Exception used to report various problems when calling apt-* and dpkg-*
+ commands.
+ """
+
+def run(command: t.Sequence[str], **kwargs) -> CP:
+ """A wrapped around subprocess.run that sets some default options."""
+ return subprocess.run(
+ command,
+ **kwargs,
+ env = {'LANG': 'en_US'},
+ capture_output = True,
+ text = True
+ )
+
+class Apt:
+ """
+ This class represents an APT instance and can be used to call apt-get
+ commands with it.
+ """
+ def __init__(self, apt_conf: str) -> None:
+ """Initialize this Apt object."""
+ self.apt_conf = apt_conf
+
+ def get(self, *args: str, **kwargs) -> CP:
+ """
+ Run apt-get with the specified arguments and raise a meaningful AptError
+ when something goes wrong.
+ """
+ command = ['apt-get', '-c', self.apt_conf, *args]
+ try:
+ cp = run(command, **kwargs)
+ except FileNotFoundError:
+ msg = _('couldnt_execute_{}_is_it_installed').format('apt-get')
+ raise AptError(msg)
+
+ if cp.returncode != 0:
+ msg = _('command_{}_failed').format(' '.join(command))
+ raise AptError(msg, cp)
+
+ return cp
+
+def cache_dir() -> Path:
+ """
+ Return the directory used to cache data (APT configurations, keyrings) to
+ speed up repeated operations.
+
+ This function first ensures the directory exists.
+ """
+ default_apt_cache_dir.mkdir(parents=True, exist_ok=True)
+ return default_apt_cache_dir
+
+class SourcesList:
+ """Representation of apt's sources.list contents."""
+ def __init__(
+ self,
+ list: t.List[str] = [],
+ codename: t.Optional[str] = None
+ ) -> None:
+ """Initialize this SourcesList."""
+ self.codename = None
+ self.list = [*list]
+ self.has_extra_entries = bool(self.list)
+
+ if codename is not None:
+ if codename not in default_lists:
+ raise DistroError(_('distro_{}_unknown').format(codename))
+
+ self.codename = codename
+ self.list.extend(default_lists[codename])
+
+ def identity(self) -> str:
+ """
+ Produce a string that uniquely identifies this sources.list contents.
+ """
+ if self.codename and not self.has_extra_entries:
+ return self.codename
+
+ return sha256('\n'.join(sorted(self.list)).encode()).digest().hex()
+
+def apt_conf(directory: Path) -> str:
+ """
+ Given local APT's directory, produce a configuration suitable for running
+ APT there.
+
+ 'directory' must not contain any special characters including quotes and
+ spaces.
+ """
+ return f'''
+Architecture "amd64";
+Dir "{directory}";
+Dir::State "{directory}/var/lib/apt";
+Dir::State::status "{directory}/var/lib/dpkg/status";
+Dir::Etc::SourceList "{directory}/etc/apt.sources.list";
+Dir::Etc::SourceParts "";
+Dir::Cache "{directory}/var/cache/apt";
+pkgCacheGen::Essential "none";
+Dir::Etc::Trusted "{directory}/etc/trusted.gpg";
+'''
+
+def apt_keyring(keys: t.List[str]) -> bytes:
+ """
+ Download the requested keys if necessary and export them as a keyring
+ suitable for passing to APT.
+
+ The keyring is returned as a bytes value that should be written to a file.
+ """
+ try:
+ from gnupg import GPG # type: ignore
+ except ModuleNotFoundError:
+ raise GpgError(_('couldnt_import_{}_is_it_installed').format('gnupg'))
+
+ gpg = GPG(keyring=str(cache_dir() / 'master_keyring.gpg'))
+ for key in keys:
+ if gpg.list_keys(keys=[key]) != []:
+ continue
+
+ if gpg.recv_keys(default_keyserver, key).imported == 0:
+ raise GpgError(_('gpg_couldnt_recv_key_{}').format(key))
+
+ return gpg.export_keys(keys, armor=False, minimal=True)
+
+def cache_apt_root(apt_root: Path, destination_zip: Path) -> None:
+ """
+ Zip an APT root directory for later use and move the zipfile to the
+ requested destination.
+ """
+ temporary_zip_path = None
+ try:
+ tmpfile = NamedTemporaryFile(suffix='.zip', prefix='tmp_',
+ dir=cache_dir(), delete=False)
+ temporary_zip_path = Path(tmpfile.name)
+
+ to_skip = {Path('etc') / 'apt.conf', Path('etc') / 'trusted.gpg'}
+
+ with zipfile.ZipFile(tmpfile, 'w') as zf:
+ for member in apt_root.rglob('*'):
+ relative = member.relative_to(apt_root)
+ if relative not in to_skip:
+ # This call will also properly add empty folders to zip file
+ zf.write(member, relative, zipfile.ZIP_DEFLATED)
+
+ shutil.move(temporary_zip_path, destination_zip)
+ finally:
+ if temporary_zip_path is not None and temporary_zip_path.exists():
+ temporary_zip_path.unlink()
+
+def setup_local_apt(directory: Path, list: SourcesList, keys: t.List[str]) \
+ -> Apt:
+ """
+ Create files and directories necessary for running APT without root rights
+ inside 'directory'.
+
+ 'directory' must not contain any special characters including quotes and
+ spaces and must be empty.
+
+ Return an Apt object that can be used to call apt-get commands.
+ """
+ apt_root = directory / 'apt_root'
+
+ conf_text = apt_conf(apt_root)
+ keyring_bytes = apt_keyring(keys)
+
+ apt_zipfile = cache_dir() / f'apt_{list.identity()}.zip'
+ if apt_zipfile.exists():
+ with zipfile.ZipFile(apt_zipfile) as zf:
+ zf.extractall(apt_root)
+
+ for to_create in (
+ apt_root / 'var' / 'lib' / 'apt' / 'partial',
+ apt_root / 'var' / 'lib' / 'apt' / 'lists',
+ apt_root / 'var' / 'cache' / 'apt' / 'archives' / 'partial',
+ apt_root / 'etc' / 'apt' / 'preferences.d',
+ apt_root / 'var' / 'lib' / 'dpkg',
+ apt_root / 'var' / 'log' / 'apt'
+ ):
+ to_create.mkdir(parents=True, exist_ok=True)
+
+ conf_path = apt_root / 'etc' / 'apt.conf'
+ trusted_path = apt_root / 'etc' / 'trusted.gpg'
+ status_path = apt_root / 'var' / 'lib' / 'dpkg' / 'status'
+ list_path = apt_root / 'etc' / 'apt.sources.list'
+
+ conf_path.write_text(conf_text)
+ trusted_path.write_bytes(keyring_bytes)
+ status_path.touch()
+ list_path.write_text('\n'.join(list.list))
+
+ apt = Apt(str(conf_path))
+ apt.get('update')
+
+ cache_apt_root(apt_root, apt_zipfile)
+
+ return apt
+
+@contextmanager
+def local_apt(list: SourcesList, keys: t.List[str]) -> t.Iterator[Apt]:
+ """
+ Create a temporary directory with proper local APT configuration in it.
+ Yield an Apt object that can be used to issue apt-get commands.
+
+ This function returns a context manager that will remove the directory on
+ close.
+ """
+ with TemporaryDirectory() as td_str:
+ td = Path(td_str)
+ yield setup_local_apt(td, list, keys)
+
+def download_apt_packages(
+ list: SourcesList,
+ keys: t.List[str],
+ packages: t.List[str],
+ destination_dir: Path,
+ with_deps: bool
+) -> t.List[str]:
+ """
+ Set up a local APT, update it using the specified sources.list configuration
+ and use it to download the specified packages.
+
+ This function downloads .deb files of packages matching the amd64
+ architecture (which includes packages with architecture 'all') as well as
+ all their corresponding source package files and (if requested) the debs
+ and source files of all their declared dependencies.
+
+ Return value is a list of names of all downloaded files.
+ """
+ install_line_regex = re.compile(r'^Inst (?P<name>\S+) \((?P<version>\S+) ')
+
+ with local_apt(list, keys) as apt:
+ if with_deps:
+ cp = apt.get('install', '--yes', '--just-print', *packages)
+
+ lines = cp.stdout.split('\n')
+ matches = [install_line_regex.match(l) for l in lines]
+ packages = [f'{m.group("name")}={m.group("version")}'
+ for m in matches if m]
+
+ if not packages:
+ raise AptError(_('apt_install_output_not_understood'), cp)
+
+ # Download .debs to indirectly to destination_dir by first placing them
+ # in a temporary subdirectory.
+ with TemporaryDirectory(dir=destination_dir) as td_str:
+ td = Path(td_str)
+ cp = apt.get('download', *packages, cwd=td)
+
+ deb_name_regex = re.compile(
+ r'''
+ ^
+ (?P<name>[^_]+)
+ _
+ (?P<ver>[^_]+)
+ _
+ .+ # architecture (or 'all')
+ \.deb
+ $
+ ''',
+ re.VERBOSE)
+
+ names_vers = []
+ downloaded = []
+ for deb_file in td.iterdir():
+ match = deb_name_regex.match(deb_file.name)
+ if match is None:
+ msg = _('apt_download_gave_bad_filename_{}')\
+ .format(deb_file.name)
+ raise AptError(msg, cp)
+
+ names_vers.append((
+ unquote(match.group('name')),
+ unquote(match.group('ver'))
+ ))
+ downloaded.append(deb_file.name)
+
+ apt.get('source', '--download-only',
+ *[f'{n}={v}' for n, v in names_vers], cwd=td)
+
+ for source_file in td.iterdir():
+ if source_file.name in downloaded:
+ continue
+
+ downloaded.append(source_file.name)
+
+ for filename in downloaded:
+ shutil.move(td / filename, destination_dir / filename)
+
+ return downloaded
+
+@contextmanager
+def piggybacked_system(
+ piggyback_def: dict,
+ foreign_packages: t.Optional[Path]
+) -> t.Iterator[Piggybacked]:
+ """
+ Resolve resources from APT. Optionally, use package files (.deb's, etc.)
+ from a specified directory instead of resolving and downloading them.
+
+ The directories and files created for the yielded Piggybacked object shall
+ be deleted when this context manager gets closed.
+ """
+ assert piggyback_def['system'] == 'apt'
+
+ with TemporaryDirectory() as td_str:
+ td = Path(td_str)
+ root = td / 'root'
+ root.mkdir()
+
+ if foreign_packages is None:
+ archives = td / 'archives'
+ archives.mkdir()
+ else:
+ archives = foreign_packages / 'apt'
+ archives.mkdir(exist_ok=True)
+
+ if [*archives.glob('*.deb')] == []:
+ sources_list = SourcesList(
+ list = piggyback_def.get('sources_list', []),
+ codename = piggyback_def.get('distribution')
+ )
+ packages = piggyback_def['packages']
+ with_deps = piggyback_def['dependencies']
+ pgp_keys = [
+ *default_keys,
+ *piggyback_def.get('trusted_keys', [])
+ ]
+
+ download_apt_packages(
+ list=sources_list,
+ keys=pgp_keys,
+ packages=packages,
+ destination_dir=archives,
+ with_deps=with_deps
+ )
+
+ for deb in archives.glob('*.deb'):
+ command = ['dpkg-deb', '-x', str(deb), str(root)]
+ try:
+ cp = run(command)
+ except FileNotFoundError:
+ msg = _('couldnt_execute_{}_is_it_installed'.format('dpkg-deb'))
+ raise AptError(msg)
+
+ if cp.returncode != 0:
+ msg = _('command_{}_failed').format(' '.join(command))
+ raise AptError(msg, cp)
+
+ docs_dir = root / 'usr' / 'share' / 'doc'
+ copyright_paths = [p / 'copyright' for p in docs_dir.iterdir()] \
+ if docs_dir.exists() else []
+ copyright_pure_paths = [PurePosixPath('.apt-root') / p.relative_to(root)
+ for p in copyright_paths if p.exists()]
+
+ standard_depends = piggyback_def.get('depend_on_base_packages', True)
+ must_depend = [{'identifier': 'apt-common-licenses'}] \
+ if standard_depends else []
+
+ yield Piggybacked(
+ archives={'apt': archives},
+ roots={'.apt-root': root},
+ package_license_files=copyright_pure_paths,
+ resource_must_depend=must_depend
+ )
diff --git a/src/hydrilla/builder/piggybacking.py b/src/hydrilla/builder/piggybacking.py
new file mode 100644
index 0000000..3be674e
--- /dev/null
+++ b/src/hydrilla/builder/piggybacking.py
@@ -0,0 +1,122 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Handling of software packaged for other distribution systems.
+#
+# This file is part of Hydrilla
+#
+# 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 contains definitions that may be reused by multiple piggybacked
+software system backends.
+"""
+
+import typing as t
+
+from pathlib import Path, PurePosixPath
+
+from ..translations import smart_gettext as _
+from .common_errors import *
+
+here = Path(__file__).resolve().parent
+
+class Piggybacked:
+ """
+ Store information about foreign resources in use.
+
+ Public attributes:
+ 'resource_must_depend' (read-only)
+ 'package_license_files' (read-only)
+ """
+ def __init__(
+ self,
+ archives: t.Dict[str, Path] = {},
+ roots: t.Dict[str, Path] = {},
+ package_license_files: t.List[PurePosixPath] = [],
+ resource_must_depend: t.List[dict] = []
+ ) -> None:
+ """
+ Initialize this Piggybacked object.
+
+ 'archives' maps piggybacked system names to directories that contain
+ package(s)' archive files. An 'archives' object may look like
+ {'apt': PosixPath('/path/to/dir/with/debs/and/tarballs')}.
+
+ 'roots' associates directory names to be virtually inserted under
+ Hydrilla source package directory with paths to real filesystem
+ directories that hold their desired contents, i.e. unpacked foreign
+ packages.
+
+ 'package_license_files' lists paths to license files that should be
+ included with the Haketilo package that will be produced. The paths are
+ to be resolved using 'roots' dictionary.
+
+ 'resource_must_depend' lists names of Haketilo packages that the
+ produced resources will additionally depend on. This is meant to help
+ distribute common licenses with a separate Haketilo package.
+ """
+ self.archives = archives
+ self.roots = roots
+ self.package_license_files = package_license_files
+ self.resource_must_depend = resource_must_depend
+
+ def resolve_file(self, file_ref_name: PurePosixPath) -> t.Optional[Path]:
+ """
+ 'file_ref_name' is a path as may appear in an index.json file. Check if
+ the file belongs to one of the roots we have and return either a path
+ to the relevant file under this root or None.
+
+ It is not being checked whether the file actually exists in the
+ filesystem.
+ """
+ parts = file_ref_name.parts
+ if not parts:
+ return None
+
+ root_path = self.roots.get(parts[0])
+ if root_path is None:
+ return None
+
+ path = root_path
+
+ for part in parts[1:]:
+ path = path / part
+
+ path = path.resolve()
+
+ try:
+ path.relative_to(root_path)
+ except ValueError:
+ raise FileReferenceError(_('loading_{}_outside_piggybacked_dir')
+ .format(file_ref_name))
+
+ return path
+
+ def archive_files(self) -> t.Iterator[t.Tuple[PurePosixPath, Path]]:
+ """
+ Yield all archive files in use. Each yielded tuple holds file's desired
+ path relative to the piggybacked archives directory to be created and
+ its current real path.
+ """
+ for system, real_dir in self.archives.items():
+ for path in real_dir.rglob('*'):
+ yield PurePosixPath(system) / path.relative_to(real_dir), path
diff --git a/src/hydrilla/common_jinja_templates b/src/hydrilla/common_jinja_templates
new file mode 160000
+Subproject 6b414822f00206b83884e7738b1311ab9d7cbf9
diff --git a/src/hydrilla/exceptions.py b/src/hydrilla/exceptions.py
new file mode 100644
index 0000000..9a0bebf
--- /dev/null
+++ b/src/hydrilla/exceptions.py
@@ -0,0 +1,38 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Custom exceptions and logging.
+#
+# This file is part of Hydrilla&Haketilo.
+#
+# Copyright (C) 2021, 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 contains utilities for reading and validation of JSON instances.
+"""
+
+class HaketiloException(Exception):
+ """
+ Type used for exceptions generated by Haketilo code. Instances of this type
+ are expected to have their error messages localized.
+ can
+ """
+ pass
diff --git a/src/hydrilla/item_infos.py b/src/hydrilla/item_infos.py
new file mode 100644
index 0000000..430bcd0
--- /dev/null
+++ b/src/hydrilla/item_infos.py
@@ -0,0 +1,699 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Reading resources, mappings and other JSON documents from the filesystem.
+#
+# This file is part of Hydrilla&Haketilo
+#
+# Copyright (C) 2021, 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.
+
+"""
+.....
+"""
+
+import sys
+
+if sys.version_info >= (3, 8):
+ from typing import Protocol
+else:
+ from typing_extensions import Protocol
+
+import enum
+import typing as t
+import dataclasses as dc
+
+from pathlib import Path, PurePosixPath
+from abc import ABC, abstractmethod
+
+from immutables import Map
+
+from . import versions, json_instances
+from .url_patterns import parse_pattern, ParsedUrl, ParsedPattern
+from .exceptions import HaketiloException
+from .translations import smart_gettext as _
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ItemSpecifier:
+ """...."""
+ identifier: str
+
+ItemSpecs = t.Tuple[ItemSpecifier, ...]
+
+SpecifierObjs = t.Sequence[t.Mapping[str, t.Any]]
+
+def make_item_specifiers_seq(spec_objs: SpecifierObjs) -> ItemSpecs:
+ return tuple(ItemSpecifier(obj['identifier']) for obj in spec_objs)
+
+def make_required_mappings(spec_objs: t.Any, schema_compat: int) -> ItemSpecs:
+ if schema_compat < 2:
+ return ()
+
+ return make_item_specifiers_seq(spec_objs)
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class FileSpecifier:
+ """...."""
+ name: str
+ sha256: str
+
+FileSpecs = t.Tuple[FileSpecifier, ...]
+
+def normalize_filename(name: str):
+ """
+ This function eliminated double slashes in file name and ensures it does not
+ try to reference parent directories.
+ """
+ path = PurePosixPath(name)
+
+ if '.' in path.parts or '..' in path.parts:
+ msg = _('err.item_info.filename_invalid_{}').format(name)
+ raise HaketiloException(msg)
+
+ return str(path)
+
+def make_file_specifiers_seq(spec_objs: SpecifierObjs) -> FileSpecs:
+ return tuple(
+ FileSpecifier(normalize_filename(obj['file']), obj['sha256'])
+ for obj
+ in spec_objs
+ )
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class GeneratedBy:
+ """...."""
+ name: str
+ version: t.Optional[str]
+
+ @staticmethod
+ def make(generated_by_obj: t.Optional[t.Mapping[str, t.Any]]) -> \
+ t.Optional['GeneratedBy']:
+ """...."""
+ if generated_by_obj is None:
+ return None
+
+ return GeneratedBy(
+ name = generated_by_obj['name'],
+ version = generated_by_obj.get('version')
+ )
+
+
+def make_eval_permission(perms_obj: t.Any, schema_compat: int) -> bool:
+ if schema_compat < 2:
+ return False
+
+ return perms_obj.get('eval', False)
+
+
+def make_cors_bypass_permission(perms_obj: t.Any, schema_compat: int) -> bool:
+ if schema_compat < 2:
+ return False
+
+ return perms_obj.get('cors_bypass', False)
+
+
+def make_version_constraint(
+ ver: t.Any,
+ schema_compat: int,
+ default: versions.VerTuple
+) -> versions.VerTuple:
+ if schema_compat < 2 or ver is None:
+ return default
+
+ return versions.normalize(ver)
+
+
+class Categorizable(Protocol):
+ """...."""
+ uuid: t.Optional[str]
+ identifier: str
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ItemIdentity:
+ repo: str
+ repo_iteration: int
+ version: versions.VerTuple
+ identifier: str
+
+
+# mypy needs to be corrected:
+# https://stackoverflow.com/questions/70999513/conflict-between-mix-ins-for-abstract-dataclasses/70999704#70999704
+@dc.dataclass(frozen=True) # type: ignore[misc]
+class ItemInfoBase(ABC, ItemIdentity, Categorizable):
+ """...."""
+ source_name: str = dc.field(hash=False, compare=False)
+ source_copyright: FileSpecs = dc.field(hash=False, compare=False)
+ uuid: t.Optional[str] = dc.field(hash=False, compare=False)
+ long_name: str = dc.field(hash=False, compare=False)
+ description: str = dc.field(hash=False, compare=False)
+ allows_eval: bool = dc.field(hash=False, compare=False)
+ allows_cors_bypass: bool = dc.field(hash=False, compare=False)
+ min_haketilo_ver: versions.VerTuple = dc.field(hash=False, compare=False)
+ max_haketilo_ver: versions.VerTuple = dc.field(hash=False, compare=False)
+ required_mappings: ItemSpecs = dc.field(hash=False, compare=False)
+ generated_by: t.Optional[GeneratedBy] = dc.field(hash=False, compare=False)
+
+ @property
+ def version_string(self) -> str:
+ return versions.version_string(self.version)
+
+ @property
+ def versioned_identifier(self) -> str:
+ """...."""
+ return f'{self.identifier}-{self.version_string}'
+
+ @property
+ def files(self) -> FileSpecs:
+ return self.source_copyright
+
+ @property
+ def compatible(self) -> bool:
+ return (self.min_haketilo_ver <= versions.haketilo_version and
+ self.max_haketilo_ver >= versions.haketilo_version)
+
+ @staticmethod
+ def _get_base_init_kwargs(
+ item_obj: t.Mapping[str, t.Any],
+ schema_compat: int,
+ repo: str,
+ repo_iteration: int
+ ) -> t.Mapping[str, t.Any]:
+ """...."""
+ source_copyright = make_file_specifiers_seq(
+ item_obj['source_copyright']
+ )
+
+ version = versions.normalize(item_obj['version'])
+
+ perms_obj = item_obj.get('permissions', {})
+
+ eval_perm = make_eval_permission(perms_obj, schema_compat)
+ cors_bypass_perm = make_cors_bypass_permission(perms_obj, schema_compat)
+
+ min_haketilo_ver = make_version_constraint(
+ ver = item_obj.get('min_haketilo_version'),
+ schema_compat = schema_compat,
+ default = versions.int_ver_min
+ )
+ max_haketilo_ver = make_version_constraint(
+ ver = item_obj.get('max_haketilo_version'),
+ schema_compat = schema_compat,
+ default = versions.int_ver_max
+ )
+
+ required_mappings = make_required_mappings(
+ item_obj.get('required_mappings', []),
+ schema_compat
+ )
+
+ generated_by = GeneratedBy.make(item_obj.get('generated_by'))
+
+ return Map(
+ repo = repo,
+ repo_iteration = repo_iteration,
+ source_name = item_obj['source_name'],
+ source_copyright = source_copyright,
+ version = version,
+ identifier = item_obj['identifier'],
+ uuid = item_obj.get('uuid'),
+ long_name = item_obj['long_name'],
+ description = item_obj['description'],
+ allows_eval = eval_perm,
+ allows_cors_bypass = cors_bypass_perm,
+ min_haketilo_ver = min_haketilo_ver,
+ max_haketilo_ver = max_haketilo_ver,
+ required_mappings = required_mappings,
+ generated_by = generated_by
+ )
+
+
+AnyInfo = t.Union['ResourceInfo', 'MappingInfo']
+
+
+class ItemType(enum.Enum):
+ RESOURCE = 'resource'
+ MAPPING = 'mapping'
+
+ @property
+ def info_class(self) -> t.Type[AnyInfo]:
+ if self == ItemType.RESOURCE:
+ return ResourceInfo
+ else:
+ return MappingInfo
+
+ @property
+ def alt_name(self) -> str:
+ if self == ItemType.RESOURCE:
+ return 'library'
+ else:
+ return 'package'
+
+ @property
+ def alt_name_plural(self) -> str:
+ if self == ItemType.RESOURCE:
+ return 'libraries'
+ else:
+ return 'packages'
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class CorrespondsToResourceDCMixin:
+ type: t.ClassVar[ItemType] = ItemType.RESOURCE
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class CorrespondsToMappingDCMixin:
+ type: t.ClassVar[ItemType] = ItemType.MAPPING
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ResourceInfo(ItemInfoBase, CorrespondsToResourceDCMixin):
+ """...."""
+ revision: int = dc.field(hash=False, compare=False)
+ dependencies: ItemSpecs = dc.field(hash=False, compare=False)
+ scripts: FileSpecs = dc.field(hash=False, compare=False)
+
+ @property
+ def version_string(self) -> str:
+ return f'{super().version_string}-{self.revision}'
+
+ @property
+ def files(self) -> FileSpecs:
+ return tuple((*self.source_copyright, *self.scripts))
+
+ @staticmethod
+ def make(
+ item_obj: t.Mapping[str, t.Any],
+ schema_compat: int,
+ repo: str,
+ repo_iteration: int
+ ) -> 'ResourceInfo':
+ """...."""
+ base_init_kwargs = ItemInfoBase._get_base_init_kwargs(
+ item_obj,
+ schema_compat,
+ repo,
+ repo_iteration
+ )
+
+ dependencies = make_item_specifiers_seq(
+ item_obj.get('dependencies', [])
+ )
+
+ scripts = make_file_specifiers_seq(
+ item_obj.get('scripts', [])
+ )
+
+ return ResourceInfo(
+ **base_init_kwargs,
+
+ revision = item_obj['revision'],
+ dependencies = dependencies,
+ scripts = scripts
+ )
+
+ @staticmethod
+ def load(
+ instance_source: json_instances.InstanceSource,
+ repo: str = '<dummyrepo>',
+ repo_iteration: int = -1
+ ) -> 'ResourceInfo':
+ """...."""
+ return _load_item_info(
+ ResourceInfo,
+ instance_source,
+ repo,
+ repo_iteration
+ )
+
+ def __lt__(self, other: 'ResourceInfo') -> bool:
+ """...."""
+ return (
+ self.identifier,
+ other.version,
+ other.revision,
+ self.repo,
+ other.repo_iteration
+ ) < (
+ other.identifier,
+ self.version,
+ self.revision,
+ other.repo,
+ self.repo_iteration
+ )
+
+def make_payloads(payloads_obj: t.Mapping[str, t.Any]) \
+ -> t.Mapping[ParsedPattern, ItemSpecifier]:
+ """...."""
+ mapping: t.List[t.Tuple[ParsedPattern, ItemSpecifier]] = []
+
+ for pattern, spec_obj in payloads_obj.items():
+ ref = ItemSpecifier(spec_obj['identifier'])
+ mapping.extend((parsed, ref) for parsed in parse_pattern(pattern))
+
+ return Map(mapping)
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class MappingInfo(ItemInfoBase, CorrespondsToMappingDCMixin):
+ """...."""
+ payloads: t.Mapping[ParsedPattern, ItemSpecifier] = \
+ dc.field(hash=False, compare=False)
+
+ @staticmethod
+ def make(
+ item_obj: t.Mapping[str, t.Any],
+ schema_compat: int,
+ repo: str,
+ repo_iteration: int
+ ) -> 'MappingInfo':
+ """...."""
+ base_init_kwargs = ItemInfoBase._get_base_init_kwargs(
+ item_obj,
+ schema_compat,
+ repo,
+ repo_iteration
+ )
+
+ return MappingInfo(
+ **base_init_kwargs,
+
+ payloads = make_payloads(item_obj.get('payloads', {}))
+ )
+
+ @staticmethod
+ def load(
+ instance_source: json_instances.InstanceSource,
+ repo: str = '<dummyrepo>',
+ repo_iteration: int = -1
+ ) -> 'MappingInfo':
+ """...."""
+ return _load_item_info(
+ MappingInfo,
+ instance_source,
+ repo,
+ repo_iteration
+ )
+
+ def __lt__(self, other: 'MappingInfo') -> bool:
+ """...."""
+ return (
+ self.identifier,
+ other.version,
+ self.repo,
+ other.repo_iteration
+ ) < (
+ other.identifier,
+ self.version,
+ other.repo,
+ self.repo_iteration
+ )
+
+
+LoadedType = t.TypeVar('LoadedType', ResourceInfo, MappingInfo)
+
+def _load_item_info(
+ info_type: t.Type[LoadedType],
+ instance_source: json_instances.InstanceSource,
+ repo: str,
+ repo_iteration: int
+) -> LoadedType:
+ """Read, validate and autocomplete a mapping/resource description."""
+ instance = json_instances.read_instance(instance_source)
+
+ schema_fmt = f'api_{info_type.type.value}_description-{{}}.schema.json'
+
+ schema_compat = json_instances.validate_instance(instance, schema_fmt)
+
+ # We know from successful validation that instance is a dict.
+ return info_type.make(
+ t.cast('t.Dict[str, t.Any]', instance),
+ schema_compat,
+ repo,
+ repo_iteration
+ )
+
+
+CategorizedInfoType = t.TypeVar(
+ 'CategorizedInfoType',
+ ResourceInfo,
+ MappingInfo
+)
+
+CategorizedType = t.TypeVar(
+ 'CategorizedType',
+ bound=Categorizable
+)
+
+CategorizedUpdater = t.Callable[
+ [t.Optional[CategorizedType]],
+ t.Optional[CategorizedType]
+]
+
+CategoryKeyType = t.TypeVar('CategoryKeyType', bound=t.Hashable)
+
+@dc.dataclass(frozen=True) # type: ignore[misc]
+class CategorizedItemInfo(
+ ABC,
+ Categorizable,
+ t.Generic[CategorizedInfoType, CategorizedType, CategoryKeyType]
+):
+ """...."""
+ SelfType = t.TypeVar(
+ 'SelfType',
+ bound = 'CategorizedItemInfo[CategorizedInfoType, CategorizedType, CategoryKeyType]'
+ )
+
+ uuid: t.Optional[str] = None
+ identifier: str = '<dummy>'
+ items: Map[CategoryKeyType, CategorizedType] = Map()
+ _initialized: bool = False
+
+ def _update(
+ self: 'SelfType',
+ key: CategoryKeyType,
+ updater: CategorizedUpdater
+ ) -> 'SelfType':
+ """...... Perform sanity checks for uuid."""
+ uuid = self.uuid
+
+ items = self.items.mutate()
+
+ updated = updater(items.get(key))
+ if updated is None:
+ items.pop(key, None)
+
+ identifier = self.identifier
+ else:
+ items[key] = updated
+
+ identifier = updated.identifier
+ if self._initialized:
+ assert identifier == self.identifier
+
+ if uuid is not None:
+ if updated.uuid is not None and uuid != updated.uuid:
+ raise HaketiloException(_('uuid_mismatch_{identifier}')
+ .format(identifier=identifier))
+ else:
+ uuid = updated.uuid
+
+ return dc.replace(
+ self,
+ identifier = identifier,
+ uuid = uuid,
+ items = items.finish(),
+ _initialized = self._initialized or updated is not None
+ )
+
+ @abstractmethod
+ def register(self: 'SelfType', info: CategorizedInfoType) -> 'SelfType':
+ ...
+
+ @abstractmethod
+ def get_all(self: 'SelfType') -> t.Sequence[CategorizedInfoType]:
+ ...
+
+ def is_empty(self) -> bool:
+ return len(self.items) == 0
+
+
+class VersionedItemInfo(
+ CategorizedItemInfo[
+ CategorizedInfoType,
+ CategorizedInfoType,
+ versions.VerTuple
+ ],
+ t.Generic[CategorizedInfoType]
+):
+ """Stores data of multiple versions of given resource/mapping."""
+ SelfType = t.TypeVar(
+ 'SelfType',
+ bound = 'VersionedItemInfo[CategorizedInfoType]'
+ )
+
+ def register(self: 'SelfType', item_info: CategorizedInfoType) \
+ -> 'SelfType':
+ """
+ Make item info queryable by version. Perform sanity checks for uuid.
+ """
+ return self._update(item_info.version, lambda old_info: item_info)
+
+ @property
+ def newest_version(self) -> versions.VerTuple:
+ """...."""
+ assert not self.is_empty()
+
+ return self.versions()[-1]
+
+ @property
+ def newest_info(self) -> CategorizedInfoType:
+ """Find and return info of the newest version of item."""
+ return self.items[self.newest_version]
+
+ def versions(self, reverse: bool = False) -> t.Sequence[versions.VerTuple]:
+ return sorted(self.items.keys(), reverse=reverse)
+
+ def get_by_ver(self, ver: t.Sequence[int]) \
+ -> t.Optional[CategorizedInfoType]:
+ """
+ Find and return info of the specified version of the item (or None if
+ absent).
+ """
+ return self.items.get(versions.normalize(ver))
+
+ def get_all(self, reverse_versions: bool = False) \
+ -> t.Sequence[CategorizedInfoType]:
+ """
+ Generate item info for all its versions, from oldest to newest unless
+ the opposite is requested.
+ """
+ versions = self.versions(reverse=reverse_versions)
+ return [self.items[ver] for ver in versions]
+
+VersionedResourceInfo = VersionedItemInfo[ResourceInfo]
+VersionedMappingInfo = VersionedItemInfo[MappingInfo]
+
+VersionedItemInfoMap = Map[str, VersionedItemInfo]
+VersionedResourceInfoMap = Map[str, VersionedResourceInfo]
+VersionedMappingInfoMap = Map[str, VersionedMappingInfo]
+
+def register_in_versioned_map(
+ map: Map[str, VersionedItemInfo[CategorizedInfoType]],
+ info: CategorizedInfoType
+) -> Map[str, VersionedItemInfo[CategorizedInfoType]]:
+ versioned_info = map.get(info.identifier, VersionedItemInfo())
+
+ return map.set(info.identifier, versioned_info.register(info))
+
+
+class MultirepoItemInfo(
+ CategorizedItemInfo[
+ CategorizedInfoType,
+ VersionedItemInfo[CategorizedInfoType],
+ t.Tuple[str, int]
+ ],
+ t.Generic[CategorizedInfoType]
+):
+ """
+ Stores data of multiple versions of given resource/mapping that may come
+ from multiple repositories.
+ """
+ SelfType = t.TypeVar(
+ 'SelfType',
+ bound = 'MultirepoItemInfo[CategorizedInfoType]'
+ )
+
+ def register(self: 'SelfType', item_info: CategorizedInfoType) \
+ -> 'SelfType':
+ """
+ Make item info queryable by repo and version. Perform sanity checks for
+ uuid.
+ """
+ def update(
+ versioned: t.Optional[VersionedItemInfo[CategorizedInfoType]]
+ ) -> VersionedItemInfo[CategorizedInfoType]:
+ if versioned is None:
+ versioned = VersionedItemInfo()
+ return versioned.register(item_info)
+
+ return self._update((item_info.repo, item_info.repo_iteration), update)
+
+ @property
+ def default_info(self) -> CategorizedInfoType:
+ """
+ Find and return info of one of the available options for the newest
+ version of item.
+ """
+ assert not self.is_empty()
+
+ return self.get_all(reverse_repos=True)[-1]
+
+ def options(self, reverse: bool = False) -> t.Sequence[t.Tuple[str, int]]:
+ return sorted(
+ self.items.keys(),
+ key = (lambda tuple: (tuple[0], 1 - tuple[1])),
+ reverse = reverse
+ )
+
+ def get_all(
+ self,
+ reverse_versions: bool = False,
+ reverse_repos: bool = False
+ ) -> t.Sequence[CategorizedInfoType]:
+ """
+ Generate item info for all its versions and options, from oldest to
+ newest version and from.
+ """
+ all_versions: t.Set[versions.VerTuple] = set()
+ for versioned in self.items.values():
+ all_versions.update(versioned.versions())
+
+ result = []
+
+ for version in sorted(all_versions, reverse=reverse_versions):
+ for option in self.options(reverse=reverse_repos):
+ info = self.items[option].get_by_ver(version)
+ if info is not None:
+ result.append(info)
+
+ return result
+
+MultirepoResourceInfo = MultirepoItemInfo[ResourceInfo]
+MultirepoMappingInfo = MultirepoItemInfo[MappingInfo]
+
+
+MultirepoItemInfoMap = Map[str, MultirepoItemInfo]
+MultirepoResourceInfoMap = Map[str, MultirepoResourceInfo]
+MultirepoMappingInfoMap = Map[str, MultirepoMappingInfo]
+
+def register_in_multirepo_map(
+ map: Map[str, MultirepoItemInfo[CategorizedInfoType]],
+ info: CategorizedInfoType
+) -> Map[str, MultirepoItemInfo[CategorizedInfoType]]:
+ multirepo_info = map.get(info.identifier, MultirepoItemInfo())
+
+ return map.set(info.identifier, multirepo_info.register(info))
+
+
+def all_map_infos(
+ map: Map[str, CategorizedItemInfo[CategorizedInfoType, t.Any, t.Any]]
+) -> t.Iterator[CategorizedInfoType]:
+ for versioned_info in map.values():
+ for item_info in versioned_info.get_all():
+ yield item_info
diff --git a/src/hydrilla/json_instances.py b/src/hydrilla/json_instances.py
new file mode 100644
index 0000000..b56a7e1
--- /dev/null
+++ b/src/hydrilla/json_instances.py
@@ -0,0 +1,221 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Handling JSON objects.
+#
+# This file is part of Hydrilla&Haketilo.
+#
+# Copyright (C) 2021, 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 contains utilities for reading and validation of JSON instances.
+"""
+
+import re
+import json
+import os
+import io
+import typing as t
+
+from pathlib import Path, PurePath
+
+from jsonschema import RefResolver, Draft7Validator # type: ignore
+
+from .translations import smart_gettext as _
+from .exceptions import HaketiloException
+from . import versions
+
+here = Path(__file__).resolve().parent
+
+_strip_comment_re = re.compile(r'''
+^ # match from the beginning of each line
+( # catch the part before '//' comment
+ (?: # this group matches either a string or a single out-of-string character
+ [^"/] |
+ "
+ (?: # this group matches any in-a-string character
+ [^"\\] | # match any normal character
+ \\[^u] | # match any escaped character like '\f' or '\n'
+ \\u[a-fA-F0-9]{4} # match an escape
+ )*
+ "
+ )*
+)
+# expect either end-of-line or a comment:
+# * unterminated strings will cause matching to fail
+# * bad comment (with '/' instead of '//') will be indicated by second group
+# having length 1 instead of 2 or 0
+(//?|$)
+''', re.VERBOSE)
+
+def strip_json_comments(text: str) -> str:
+ """
+ Accept JSON text with optional C++-style ('//') comments and return the text
+ with comments removed. Consecutive slashes inside strings are handled
+ properly. A spurious single slash ('/') shall generate an error. Errors in
+ JSON itself shall be ignored.
+ """
+ stripped_text = []
+ for line_num, line in enumerate(text.split('\n'), start=1):
+ match = _strip_comment_re.match(line)
+
+ if match is None: # unterminated string
+ # ignore this error, let the json module report it
+ stripped = line
+ elif len(match[2]) == 1:
+ msg_fmt = _('bad_json_comment_line_{line_num}_char_{char_num}')
+
+ raise HaketiloException(msg_fmt.format(
+ line_num = line_num,
+ char_num = len(match[1]) + 1
+ ))
+ else:
+ stripped = match[1]
+
+ stripped_text.append(stripped)
+
+ return '\n'.join(stripped_text)
+
+_schema_name_re = re.compile(r'''
+(?P<name_base>[^/]*)
+-
+(?P<ver>
+ (?P<major>[1-9][0-9]*)
+ (?: # this repeated group matches the remaining version numbers
+ \.
+ (?:[1-9][0-9]*|0)
+ )*
+)
+\.schema\.json
+$
+''', re.VERBOSE)
+
+schema_paths: t.Dict[str, Path] = {}
+for path in (here / 'schemas').rglob('*.schema.json'):
+ match = _schema_name_re.match(path.name)
+ assert match is not None
+
+ schema_name_base = match.group('name_base')
+ schema_ver_list = match.group('ver').split('.')
+
+ for i in range(len(schema_ver_list)):
+ schema_ver = '.'.join(schema_ver_list[:i+1])
+ schema_paths[f'{schema_name_base}-{schema_ver}.schema.json'] = path
+
+schema_paths.update([(f'https://hydrilla.koszko.org/schemas/{name}', path)
+ for name, path in schema_paths.items()])
+
+schemas: t.Dict[Path, t.Dict[str, t.Any]] = {}
+
+class UnknownSchemaError(HaketiloException):
+ pass
+
+def _get_schema(schema_name: str) -> t.Dict[str, t.Any]:
+ """Return loaded JSON of the requested schema. Cache results."""
+ path = schema_paths.get(schema_name)
+ if path is None:
+ raise UnknownSchemaError(_('unknown_schema_{}').format(schema_name))
+
+ if path not in schemas:
+ schemas[path] = json.loads(path.read_text())
+
+ return schemas[path]
+
+def validator_for(schema: t.Union[str, t.Dict[str, t.Any]]) -> Draft7Validator:
+ """
+ Prepare a validator for the provided schema.
+
+ Other schemas under '../schemas' can be referenced.
+ """
+ if isinstance(schema, str):
+ schema = _get_schema(schema)
+
+ resolver = RefResolver(
+ base_uri=schema['$id'],
+ referrer=schema,
+ handlers={'https': _get_schema}
+ )
+
+ return Draft7Validator(schema, resolver=resolver)
+
+def parse_instance(text: str) -> object:
+ """Parse 'text' as JSON with additional '//' comments support."""
+ return json.loads(strip_json_comments(text))
+
+InstanceSource = t.Union[Path, str, io.TextIOBase, t.Dict[str, t.Any], bytes]
+
+def read_instance(instance_or_path: InstanceSource) -> object:
+ """...."""
+ if isinstance(instance_or_path, dict):
+ return instance_or_path
+
+ if isinstance(instance_or_path, bytes):
+ encoding = json.detect_encoding(instance_or_path)
+ text = instance_or_path.decode(encoding)
+ elif isinstance(instance_or_path, io.TextIOBase):
+ try:
+ text = instance_or_path.read()
+ finally:
+ instance_or_path.close()
+ else:
+ text = Path(instance_or_path).read_text()
+
+ try:
+ return parse_instance(text)
+ except:
+ if isinstance(instance_or_path, str) or \
+ isinstance(instance_or_path, Path):
+ fmt = _('err.util.text_in_{}_not_valid_json')
+ raise HaketiloException(fmt.format(instance_or_path))
+ else:
+ raise HaketiloException(_('err.util.text_not_valid_json'))
+
+def get_schema_version(instance: object) -> versions.VerTuple:
+ """
+ Parse passed object's "$schema" property and return the schema version tuple.
+ """
+ ver_str: t.Optional[str] = None
+
+ if isinstance(instance, dict) and type(instance.get('$schema')) is str:
+ match = _schema_name_re.search(instance['$schema'])
+ ver_str = match.group('ver') if match else None
+
+ if ver_str is not None:
+ return versions.parse_normalize(ver_str)
+ else:
+ raise HaketiloException(_('no_schema_number_in_instance'))
+
+def get_schema_major_number(instance: object) -> int:
+ """
+ Parse passed object's "$schema" property and return the major number of
+ schema version.
+ """
+ return get_schema_version(instance)[0]
+
+def validate_instance(instance: object, schema_name_fmt: str) -> int:
+ """...."""
+ major = get_schema_major_number(instance)
+ schema_name = schema_name_fmt.format(major)
+ validator = validator_for(schema_name)
+
+ validator.validate(instance)
+
+ return major
diff --git a/src/hydrilla/locales/en_US/LC_MESSAGES/messages.po b/src/hydrilla/locales/en_US/LC_MESSAGES/messages.po
new file mode 100644
index 0000000..f40397b
--- /dev/null
+++ b/src/hydrilla/locales/en_US/LC_MESSAGES/messages.po
@@ -0,0 +1,1511 @@
+# SPDX-License-Identifier: CC0-1.0
+# English translations for Hydrilla&Haketilo.
+#
+# Copyright (C) 2021-2022 Wojtek Kosior <koszko@koszko.org>
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+msgid ""
+msgstr ""
+"Project-Id-Version: hydrilla 2.0\n"
+"Report-Msgid-Bugs-To: koszko@koszko.org\n"
+"POT-Creation-Date: 2022-11-23 19:21+0100\n"
+"PO-Revision-Date: 2022-02-12 00:00+0000\n"
+"Last-Translator: Wojtek Kosior <koszko@koszko.org>\n"
+"Language: en_US\n"
+"Language-Team: en_US <koszko@koszko.org>\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.9.0\n"
+
+#: src/hydrilla/builder/build.py:81 src/hydrilla/builder/local_apt.py:120
+#: src/hydrilla/builder/local_apt.py:426
+msgid "couldnt_execute_{}_is_it_installed"
+msgstr "Could not execute '{}'. Is the tool installed and reachable via PATH?"
+
+#: src/hydrilla/builder/build.py:85 src/hydrilla/builder/local_apt.py:124
+#: src/hydrilla/builder/local_apt.py:430
+msgid "command_{}_failed"
+msgstr "The following command finished execution with a non-zero exit status: {}"
+
+#: src/hydrilla/builder/build.py:201
+msgid "path_contains_double_dot_{}"
+msgstr ""
+"Attempt to load '{}' which includes a forbidden parent reference ('..') "
+"in the path."
+
+#: src/hydrilla/builder/build.py:210
+msgid "loading_{}_outside_package_dir"
+msgstr "Attempt to load '{}' which lies outside package source directory."
+
+#: src/hydrilla/builder/build.py:214
+msgid "loading_reserved_index_json"
+msgstr "Attempt to load 'index.json' which is a reserved filename."
+
+#: src/hydrilla/builder/build.py:221
+msgid "referenced_file_{}_missing"
+msgstr "Referenced file '{}' is missing."
+
+#: src/hydrilla/builder/build.py:412
+msgid "report_spdx_not_in_copyright_list"
+msgstr ""
+"Told to generate 'report.spdx' but 'report.spdx' is not listed among "
+"copyright files. Refusing to proceed."
+
+#: src/hydrilla/builder/build.py:489
+msgid "build_package_from_srcdir_to_dstdir"
+msgstr ""
+"Build Hydrilla package from `scrdir` and write the resulting files under "
+"`dstdir`."
+
+#: src/hydrilla/builder/build.py:491
+msgid "source_directory_to_build_from"
+msgstr "Source directory to build from."
+
+#: src/hydrilla/builder/build.py:493
+msgid "path_instead_of_index_json"
+msgstr ""
+"Path to file to be processed instead of index.json (if not absolute, "
+"resolved relative to srcdir)."
+
+#: src/hydrilla/builder/build.py:495
+msgid "path_instead_for_piggyback_files"
+msgstr ""
+"Path to a non-standard directory with foreign packages' archive files to "
+"use."
+
+#: src/hydrilla/builder/build.py:497
+msgid "built_package_files_destination"
+msgstr "Destination directory to write built package files to."
+
+#: src/hydrilla/builder/build.py:499
+#: src/hydrilla/mitmproxy_launcher/launch.py:66
+#: src/hydrilla/server/serve.py:211 src/hydrilla/server/serve.py:229
+#: src/hydrilla/server/serve.py:269
+#, python-format
+msgid "%(prog)s_%(version)s_license"
+msgstr ""
+"%(prog)s %(version)s\n"
+"Copyright (C) 2021,2022 Wojtek Kosior and contributors.\n"
+"License AGPLv3+: GNU AGPL version 3 or later "
+"<https://gnu.org/licenses/gpl.html>\n"
+"This is free software: you are free to change and redistribute it.\n"
+"There is NO WARRANTY, to the extent permitted by law."
+
+#: src/hydrilla/builder/build.py:500 src/hydrilla/server/serve.py:230
+#: src/hydrilla/server/serve.py:270
+msgid "version_printing"
+msgstr "Print version information and exit."
+
+#: src/hydrilla/builder/common_errors.py:58
+msgid "STDOUT_OUTPUT_heading"
+msgstr "## Command's standard output ##"
+
+#: src/hydrilla/builder/common_errors.py:61
+msgid "STDERR_OUTPUT_heading"
+msgstr "## Command's standard error output ##"
+
+#: src/hydrilla/builder/local_apt.py:153
+msgid "distro_{}_unknown"
+msgstr "Attempt to use an unknown software distribution '{}'."
+
+#: src/hydrilla/builder/local_apt.py:197
+msgid "couldnt_import_{}_is_it_installed"
+msgstr ""
+"Could not import '{}'. Is the module installed and visible to this Python"
+" instance?"
+
+#: src/hydrilla/builder/local_apt.py:205
+msgid "gpg_couldnt_recv_key_{}"
+msgstr "Could not import PGP key '{}'."
+
+#: src/hydrilla/builder/local_apt.py:325
+msgid "apt_install_output_not_understood"
+msgstr "The output of an 'apt-get install' command was not understood."
+
+#: src/hydrilla/builder/local_apt.py:351
+msgid "apt_download_gave_bad_filename_{}"
+msgstr "The 'apt-get download' command produced a file with unexpected name '{}'."
+
+#: src/hydrilla/builder/piggybacking.py:109
+msgid "loading_{}_outside_piggybacked_dir"
+msgstr ""
+"Attempt to load '{}' which lies outside piggybacked packages files root "
+"directory."
+
+#: src/hydrilla/item_infos.py:88
+msgid "err.item_info.filename_invalid_{}"
+msgstr "Item definition conatains an illegal path: {}"
+
+#: src/hydrilla/item_infos.py:511
+#, python-brace-format
+msgid "uuid_mismatch_{identifier}"
+msgstr "Two different uuids were specified for item '{identifier}'."
+
+#: src/hydrilla/json_instances.py:84
+msgid "bad_json_comment_line_{line_num}_char_{char_num}"
+msgstr ""
+"JSON document contains an invalid comment at line {line_num}, char "
+"{char_num}."
+
+#: src/hydrilla/json_instances.py:135
+msgid "unknown_schema_{}"
+msgstr "JSON document declares its schema as '{}' which is not a known schema."
+
+#: src/hydrilla/json_instances.py:186
+msgid "err.util.text_in_{}_not_valid_json"
+msgstr "Not a valid JSON file: {}"
+
+#: src/hydrilla/json_instances.py:189
+msgid "err.util.text_not_valid_json"
+msgstr "Not a valid JSON file."
+
+#: src/hydrilla/json_instances.py:204
+msgid "no_schema_number_in_instance"
+msgstr "JSON schema number is missing from a document."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:55
+msgid "cli_help.haketilo"
+msgstr ""
+"Run Haketilo proxy.\n"
+"\n"
+"This command starts Haketilo as a local HTTP proxy which a web browser "
+"can then use."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:57
+msgid "cli_opt.haketilo.listen_host"
+msgstr "IP address the proxy should listen on."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:59
+msgid "cli_opt.haketilo.port"
+msgstr "TCP port number the proxy should listen on."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:61
+msgid "cli_opt.haketilo.launch_browser"
+msgstr ""
+"Whether Haketilo should try to open its landing page in your default "
+"browser. Defaults to yes ('-L')."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:64
+msgid "cli_opt.haketilo.dir_defaults_to_{}"
+msgstr "Data directory for Haketilo to use. Defaults to \"{}\"."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:67
+msgid "cli_opt.haketilo.version"
+msgstr "Print version information and exit"
+
+#: src/hydrilla/proxy/addon.py:195
+msgid "warn.proxy.setting_already_configured_{}"
+msgstr ""
+"Attempt was made to configure Mitmproxy addon's option '{}' which has "
+"already been configured."
+
+#: src/hydrilla/proxy/addon.py:230
+msgid "warn.proxy.couldnt_launch_browser"
+msgstr ""
+"Failed to open a URL in a web browser. Do you have a default web browser "
+"configured?"
+
+#: src/hydrilla/proxy/addon.py:271
+msgid "err.proxy.unknown_error_{}_try_again"
+msgstr ""
+"Haketilo experienced an error. Try again.\n"
+"\n"
+"{}"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:39
+msgid "info.base.title"
+msgstr "Page info"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:44
+msgid "info.base.heading.page_info"
+msgstr "Haketilo page handling details"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:48
+msgid "info.base.page_url_label"
+msgstr "Page URL"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:56
+msgid "info.base.page_policy_label"
+msgstr "Active policy"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:70
+msgid "info.base.more_config_options_label"
+msgstr "Configure"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:78
+msgid "info.base.this_site_script_blocking_button"
+msgstr "JS blocking on this site"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:81
+msgid "info.base.this_site_payload_button"
+msgstr "Payload for this site"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:84
+msgid "info.base.this_page_script_blocking_button"
+msgstr "JS blocking on this page"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:87
+msgid "info.base.this_page_payload_button"
+msgstr "Payload for this page"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja:13
+msgid "info.js_error_blocked.html"
+msgstr ""
+"Haketilo experienced an error when deciding the policy to apply on this "
+"page. As a security measure, it is going to block JavaScript on pages "
+"where this happens. This should not normally occur, you may consider <a "
+"href=\"mailto:koszko@koszko.org\">reporting the issue</a>."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja:18
+msgid "info.js_error_blocked.stacktrace"
+msgstr "Error details"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_fallback_allowed_info.html.jinja:13
+msgid "info.js_fallback_allowed"
+msgstr "JavaScript is allowed to execute on this page. This is the default policy."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_fallback_blocked_info.html.jinja:13
+msgid "info.js_fallback_blocked"
+msgstr ""
+"JavaScript is blocked from executing on this page. This is the default "
+"policy."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_rule_allowed_info.html.jinja:13
+msgid "info.js_allowed.html.rule{url}_is_used"
+msgstr ""
+"JavaScript is allowed to execute on this page. A <a href=\"{url}\" "
+"target=\"_blank\">script allowing rule</a> has been explicitly configured"
+" by the user."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_rule_blocked_info.html.jinja:13
+msgid "info.js_blocked.html.rule{url}_is_used"
+msgstr ""
+"JavaScript is blocked from executing on this page. A <a href=\"{url}\" "
+"target=\"_blank\">script blocking rule</a> has been explicitly configured"
+" by the user."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_rule_info.html.jinja:32
+msgid "info.rule.matched_pattern_label"
+msgstr "Matched rule pattern"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja:36
+msgid "info.payload.html.package_{identifier}{url}_is_used"
+msgstr ""
+"This page is handled by package with the name '<a href=\"{url}\" "
+"target=\"_blank\">{identifier}</a>'. The package has been explicitly "
+"configured by the user and can make changes to the page."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja:43
+msgid "info.payload.matched_pattern_label"
+msgstr "Matched package pattern"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/special_page_info.html.jinja:13
+msgid "info.special_page"
+msgstr "This is a special page. It is exempt from the usual Haketilo policies."
+
+#: src/hydrilla/proxy/policies/payload_resource.py:249
+msgid "api.file_not_found"
+msgstr "Requested file could not be found."
+
+#: src/hydrilla/proxy/policies/payload_resource.py:365
+msgid "api.resource_not_enabled_for_access"
+msgstr "Requested resource is not enabled for access."
+
+#: src/hydrilla/proxy/state_impl/concrete_state.py:127
+msgid "err.proxy.unknown_db_schema"
+msgstr ""
+"Haketilo's data files have been altered, possibly by a newer version of "
+"Haketilo."
+
+#: src/hydrilla/proxy/state_impl/concrete_state.py:161
+msgid "err.proxy.no_sqlite_foreign_keys"
+msgstr ""
+"This installation of Haketilo uses an SQLite version which does not "
+"support foreign key constraints."
+
+#: src/hydrilla/proxy/state_impl/concrete_state.py:326
+msgid "warn.proxy.failed_to_register_landing_page_at_{}"
+msgstr "Failed to register landing page at \"{}\"."
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:82
+msgid "web_ui.base.nav.home"
+msgstr "Home"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:83
+msgid "web_ui.base.nav.rules"
+msgstr "Script blocking"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:84
+msgid "web_ui.base.nav.packages"
+msgstr "Packages"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:85
+msgid "web_ui.base.nav.libraries"
+msgstr "Libraries"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:86
+msgid "web_ui.base.nav.repos"
+msgstr "Repositories"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:87
+msgid "web_ui.base.nav.import"
+msgstr "Import"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:23
+msgid "web_ui.import.title"
+msgstr "Import items"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:41
+msgid "web_ui.import.heading"
+msgstr "Import items"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:43
+msgid "web_ui.import.heading_import_from_file"
+msgstr "From ZIP file"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:49
+msgid "web_ui.err.uploaded_file_not_zip"
+msgstr "The uploaded file is not a valid ZIP file."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:53
+msgid "web_ui.err.invalid_uploaded_malcontent"
+msgstr "The uploaded archive does not contain valid Haketilo malcontent."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:61
+msgid "web_ui.import.choose_zipfile_button"
+msgstr "Select file"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:68
+msgid "web_ui.import.install_from_file_button"
+msgstr "Import from selected file"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:76
+msgid "web_ui.import.heading_import_ad_hoc"
+msgstr "Ad hoc"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:81
+msgid "web_ui.err.invalid_ad_hoc_package"
+msgstr "The ad hoc package being imported contains errors."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:87
+msgid "web_ui.import.identifier_field_label"
+msgstr "Identifier"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:89
+msgid "web_ui.err.invalid_ad_hoc_identifier"
+msgstr "Chosen identifier is not valid."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:93
+msgid "web_ui.import.long_name_field_label"
+msgstr "Long name (optional)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:96
+msgid "web_ui.import.version_field_label"
+msgstr "Version (optional)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:98
+msgid "web_ui.err.invalid_ad_hoc_version"
+msgstr "Chosen version is not valid."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:102
+msgid "web_ui.import.description_field_label"
+msgstr "Description (optional)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:105
+msgid "web_ui.import.patterns_field_label"
+msgstr "URL patterns (each on its own line)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:109
+msgid "web_ui.err.invalid_ad_hoc_patterns"
+msgstr "Chosen patterns are not valid."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:113
+msgid "web_ui.import.script_text_field_label"
+msgstr "JavaScript to execute on pages that match one of the patterns"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:116
+msgid "web_ui.import.lic_text_field_label"
+msgstr "Package license text (optional)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:121
+msgid "web_ui.import.install_ad_hoc_button"
+msgstr "Add new package"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:23
+msgid "web_ui.home.title"
+msgstr "Welcome"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:35
+#: src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja:44
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:30
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:35
+msgid "web_ui.err.file_installation_error"
+msgstr "Failed to install needed items from repository."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:39
+#: src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja:48
+msgid "web_ui.err.impossible_situation_error"
+msgstr "Item constraints prevent the action from succeeding."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:43
+msgid "web_ui.home.heading.welcome_to_haketilo"
+msgstr "Welcome to Haketilo!"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:47
+msgid "web_ui.home.this_is_haketilo_page"
+msgstr ""
+"This is a virtual site hosted locally by Haketilo. You can use it to "
+"configure Haketilo proxy."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:53
+msgid "web_ui.home.heading.about_haketilo"
+msgstr "About this tool"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:57
+msgid "web_ui.home.html.haketilo_is_blah_blah"
+msgstr ""
+"Haketilo is a tool that gives users more control over their web browsing."
+" It can block unwanted JavaScript software on web pages as well as add "
+"custom logic to them. Haketilo was orignally developed as a browser "
+"extension but has since been made into an HTTP proxy. It is built on top "
+"of the popular <a href=\"https://mitmproxy.org/\">mitmproxy</a>."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:61
+msgid "web_ui.home.html.see_haketilo_doc_{url}"
+msgstr ""
+"Helpful information concerning use of this tool can be found in "
+"Haketilo's <a href=\"{url}\">embedded documentation</a>."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:70
+msgid "web_ui.home.heading.configuring_browser_for_haketilo"
+msgstr "Configuring the browser for Haketilo"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:74
+msgid "web_ui.home.html.to_add_certs_do_xyz"
+msgstr ""
+"Haketilo proxy works by modifying data exchanged by your browser and web "
+"servers. This works without problems for http:// URLs. For https:// URLs,"
+" however, the transmitted data is protected from modification using "
+"cryptography. For your browser to trust the data modified by Haketilo, it"
+" needs to be told to recognize proxy's cryptographic certificate. If you "
+"haven't already, download the right certificate from <a "
+"href=\"http://mitm.it\">this page</a> and add it to your operating "
+"system, browser or both."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:81
+msgid "web_ui.home.heading.options"
+msgstr "Global options"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:84
+msgid "web_ui.home.choose_language_label"
+msgstr "Choose your language"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:103
+msgid "web_ui.home.mapping_usage_mode_label"
+msgstr "Package usage mode"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:114
+msgid "web_ui.home.packages_are_used_when_enabled"
+msgstr ""
+"Haketilo is currently configured to only use packages that were "
+"explicitly enabled."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:117
+msgid "web_ui.home.user_gets_asked_whether_to_enable_package"
+msgstr ""
+"Haketilo is currently configured to ask whenever a package is found that "
+"could be used for the current site."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:121
+msgid "web_ui.home.packages_are_used_automatically"
+msgstr ""
+"Haketilo is currently configured to automatically use packages that are "
+"available for the current site."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:128
+msgid "web_ui.home.use_enabled_button"
+msgstr "Use when enabled"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:131
+msgid "web_ui.home.use_question_button"
+msgstr "Ask whether to use"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:134
+msgid "web_ui.home.use_auto_button"
+msgstr "Use automatically"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:141
+msgid "web_ui.home.script_blocking_mode_label"
+msgstr "Default scripts treatment"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:151
+msgid "web_ui.home.scripts_are_allowed_by_default"
+msgstr ""
+"By default Haketilo currently allows JavaScript sent by websites to the "
+"browser to execute."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:154
+msgid "web_ui.home.scripts_are_blocked_by_default"
+msgstr ""
+"By default Haketilo currently blocks JavaScript sent by websites to the "
+"browser from executing."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:158
+msgid "web_ui.home.allow_scripts_button"
+msgstr "Allow scripts"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:159
+msgid "web_ui.home.block_scripts_button"
+msgstr "Block scripts"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:170
+msgid "web_ui.home.advanced_features_label"
+msgstr "Advanced features"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:180
+msgid "web_ui.home.user_is_advanced_user"
+msgstr "Interface features for advanced users are currently enabled."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:183
+msgid "web_ui.home.user_is_simple_user"
+msgstr "Interface features for advanced users are currently disabled."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:190
+msgid "web_ui.home.user_make_advanced_button"
+msgstr "Enable"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:193
+msgid "web_ui.home.user_make_simple_button"
+msgstr "Disable"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:201
+msgid "web_ui.home.update_waiting_label"
+msgstr "Package updates"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:204
+msgid "web_ui.home.update_is_awaiting"
+msgstr "There might be some enabled items that can be updated to newer versions."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:207
+msgid "web_ui.home.update_items_button"
+msgstr "Update now"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:219
+msgid "web_ui.home.orphans_label"
+msgstr "Orphans"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:225
+msgid "web_ui.home.orphans_to_delete_{mappings}"
+msgstr "Haketilo is holding some unused packages that can be removed ({mappings})."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:229
+msgid "web_ui.home.orphans_to_delete_exist"
+msgstr "Haketilo is holding some unused libraries that can be removed."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:233
+msgid "web_ui.home.orphans_to_delete_{mappings}_{resources}"
+msgstr ""
+"Haketilo is holding some unused items that can be removed (packages: "
+"{mappings}; libraries: {resources})."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:242
+msgid "web_ui.home.prune_orphans_button"
+msgstr "Prune orphans"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:253
+msgid "web_ui.home.popup_settings_label"
+msgstr "Popup settings"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:269
+msgid "web_ui.home.configure_popup_settings_on_pages_with"
+msgstr "Configure popup settings on pages with"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:275
+msgid "web_ui.home.popup_settings_jsallowed_button"
+msgstr "JS allowed"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:276
+msgid "web_ui.home.popup_settings_jsblocked_button"
+msgstr "JS blocked"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:277
+msgid "web_ui.home.popup_settings_payloadon_button"
+msgstr "Payload used"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:327
+msgid "web_ui.home.popup_no_button"
+msgstr "Disable popup"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:330
+msgid "web_ui.home.popup_yes_button"
+msgstr "Enable popup"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:340
+msgid "web_ui.home.jsallowed_popup_yes"
+msgstr ""
+"Haketilo currently makes it possible to open its popup window on pages "
+"where native JS has been allowed to execute. This is a convenience that "
+"comes at a price of greater risk of user fingerprinting."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:342
+msgid "web_ui.home.jsallowed_popup_no"
+msgstr ""
+"Haketilo currently does not make it possible to open its popup window on "
+"pages with their native JS allowed. This setting is less convenient but "
+"decreases the risk of user fingerprinting."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:348
+msgid "web_ui.home.jsblocked_popup_yes"
+msgstr ""
+"Haketilo currently makes it possible to open its popup window on pages "
+"where native JS has been blocked from executing."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:350
+msgid "web_ui.home.jsblocked_popup_no"
+msgstr ""
+"Haketilo currently does not make it possible to open its popup window on "
+"pages where native JS has been blocked from executing."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:356
+msgid "web_ui.home.payloadon_popup_yes"
+msgstr ""
+"Haketilo currently makes it possible to open its popup window on pages "
+"where payload is used."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:358
+msgid "web_ui.home.payloadon_popup_no"
+msgstr ""
+"Haketilo currently does not make it possible to open its popup window on "
+"pages where payload is used."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:363
+msgid "web_ui.home.popup_can_be_opened_by"
+msgstr ""
+"When enabled on given page, popup dialog can be opened by typing big "
+"letters \"HKT\". It can be subsequently closed by clicking anywhere on "
+"the dark area around it."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja:52
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:34
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:39
+msgid "web_ui.err.repo_communication_error"
+msgstr "Couldn't communicate with repository."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:61
+msgid "web_ui.err.item_not_compatible"
+msgstr "This item is not compatible with current Haketilo version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:68
+msgid "web_ui.items.single_version.identifier_label"
+msgstr "Identifier"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:76
+msgid "web_ui.items.single_version.version_label"
+msgstr "Version"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:85
+msgid "web_ui.items.single_version.uuid_label"
+msgstr "UUID"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:95
+msgid "web_ui.items.single_version.description_label"
+msgstr "Description"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:104
+msgid "web_ui.items.single_version.licenses_label"
+msgstr "License and copyright files"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:110
+msgid "web_ui.items.single_version.no_license_files"
+msgstr "There are no designated files with legal information."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:117
+msgid "web_ui.items.single_version.required_mappings_label"
+msgstr "Required packages"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:137
+msgid "web_ui.items.single_version.min_haketilo_ver_label"
+msgstr "Minimum compatible Haketilo version"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:147
+msgid "web_ui.items.single_version.max_haketilo_ver_label"
+msgstr "Maximum compatible Haketilo version"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:164
+msgid "web_ui.items.single_version.install_uninstall_label"
+msgstr "Installation status"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:171
+msgid "web_ui.items.single_version.retry_install_button"
+msgstr "Retry installation"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:175
+msgid "web_ui.items.single_version.leave_uninstalled_button"
+msgstr "Leave uninstalled"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:179
+msgid "web_ui.items.single_version.install_button"
+msgstr "Install"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:181
+msgid "web_ui.items.single_version.uninstall_button"
+msgstr "Uninstall"
+
+#: src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja:23
+msgid "web_ui.libraries.title"
+msgstr "Libraries"
+
+#: src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja:40
+msgid "web_ui.libraries.heading"
+msgstr "Available libraries"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja:23
+msgid "web_ui.items.single.library.title"
+msgstr "Library view"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja:27
+msgid "web_ui.items.single.library.heading.name_{}"
+msgstr "Libraries named '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja:37
+msgid "web_ui.items.single.library.version_list_heading"
+msgstr "Available versions"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:24
+msgid "web_ui.items.single_version.library.title"
+msgstr "Library version view"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:30
+msgid "web_ui.items.single_version.library_local.heading.name_{}"
+msgstr "Local library '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:35
+msgid "web_ui.items.single_version.library.heading.name_{}"
+msgstr "Library '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:42
+msgid "web_ui.items.single_version.library.install_failed"
+msgstr "Couldn't install this library version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:46
+msgid "web_ui.items.single_version.library.is_installed"
+msgstr "Library is currently installed."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:50
+msgid "web_ui.items.single_version.library.is_not_installed"
+msgstr "Library is not currently installed."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:54
+msgid "web_ui.items.single_version.library.version_list_heading"
+msgstr "Other available versions of the library"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:58
+msgid "web_ui.items.single_version.library.scripts_label"
+msgstr "Scripts"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:64
+msgid "web_ui.items.single_version.library.no_script_files"
+msgstr "There are no JavaScript files in this library."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:71
+msgid "web_ui.items.single_version.library.deps_label"
+msgstr "Dependencies"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:86
+msgid "web_ui.items.single_version.library.enabled_label"
+msgstr "Usage status"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:90
+msgid "web_ui.items.single_version.library.item_required"
+msgstr "This library version is required by an enabled package."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:95
+msgid "web_ui.items.single_version.library.item_not_activated"
+msgstr "This library version is not used by any package enabled by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:97
+msgid "web_ui.items.single_version.library.item_will_be_asked_about"
+msgstr "This library version is not used by any package enabled by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:100
+msgid "web_ui.items.single_version.library.item_auto_activated"
+msgstr ""
+"This library version is used by some package. The package has not been "
+"explicitly configured by the user but is going to be activated "
+"automatically."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:23
+msgid "web_ui.items.single.package.title"
+msgstr "Package view"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:27
+msgid "web_ui.items.single.package.heading.name_{}"
+msgstr "Packages named '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:40
+msgid "web_ui.items.single.package.enabled_label"
+msgstr "Usage status"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:46
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:117
+msgid "web_ui.items.unenable_button"
+msgstr "Forget"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:47
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:118
+msgid "web_ui.items.disable_button"
+msgstr "Disable"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:48
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:119
+msgid "web_ui.items.enable_button"
+msgstr "Enable"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:53
+msgid "web_ui.items.single.package.item_not_enabled"
+msgstr "The package has not been explicitly configured by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:56
+msgid "web_ui.items.single.package.item_disabled"
+msgstr "The package has been explicitly disabled by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:60
+msgid "web_ui.items.single.package.item_enabled"
+msgstr "The package has been enabled by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:75
+msgid "web_ui.items.single.package.pinning_label"
+msgstr "Enabled package pinning"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:81
+msgid "web_ui.items.single.package.unpin_button"
+msgstr "Unpin"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:86
+msgid "web_ui.items.single.package.pin_local_repo_button"
+msgstr "Pin to local packages"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:89
+msgid "web_ui.items.single.package.pin_repo_button"
+msgstr "Pin to repository"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:92
+msgid "web_ui.items.single.package.pin_ver_button"
+msgstr "Pin to current version"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:97
+msgid "web_ui.items.single.package.not_pinned"
+msgstr "The package is not pinned to any version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:101
+msgid "web_ui.items.single.package.pinned_repo_local"
+msgstr "The package is pinned to only use locally installed versions."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:104
+msgid "web_ui.items.single.package.pinned_repo_{}"
+msgstr "The package is pinned to only use versions from repository '{}'."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:111
+msgid "web_ui.items.single.package.pinned_ver"
+msgstr "The package is pinned to a specific version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:126
+msgid "web_ui.items.single.package.version_list_heading"
+msgstr "Available versions"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:24
+msgid "web_ui.items.single_version.package.title"
+msgstr "Package version view"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:30
+msgid "web_ui.items.single_version.package_local.heading.name_{}"
+msgstr "Local package '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:35
+msgid "web_ui.items.single_version.package.heading.name_{}"
+msgstr "Package '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:42
+msgid "web_ui.items.single_version.package.install_failed"
+msgstr "Couldn't install this package version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:46
+msgid "web_ui.items.single_version.package.is_installed"
+msgstr "Package is currently installed."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:50
+msgid "web_ui.items.single_version.package.is_not_installed"
+msgstr "Package is not currently installed."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:54
+msgid "web_ui.items.single_version.package.version_list_heading"
+msgstr "Other available versions of the package"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:58
+msgid "web_ui.items.single_version.package.payloads_label"
+msgstr "Payloads"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:101
+msgid "web_ui.items.single_version.package.no_payloads"
+msgstr "This package has no payloads."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:107
+msgid "web_ui.items.single_version.package.enabled_label"
+msgstr "Usage status"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:128
+msgid "web_ui.items.single_version.package.item_not_activated"
+msgstr "This package is not enabled. This version won't be used."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:130
+msgid "web_ui.items.single_version.package.item_will_be_asked_about"
+msgstr ""
+"This package is not currently enabled. You will be asked whether to "
+"enable this version of it when you visit a website where it can be used."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:133
+msgid "web_ui.items.single_version.package.item_auto_activated"
+msgstr ""
+"This package version has not been explicitly enabled but it is going to "
+"be used automatically."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:137
+msgid "web_ui.items.single_version.package.item_disabled"
+msgstr "All versions of the package have been explicitly disabled by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:141
+msgid "web_ui.items.single_version.package.item_enabled"
+msgstr "The package has been enabled by the user."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:156
+msgid "web_ui.items.single_version.package.pinning_label"
+msgstr "Enabled package pinning"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:168
+msgid "web_ui.items.single_version.unpin_button"
+msgstr "Unpin"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:173
+msgid "web_ui.items.single_version.not_pinned"
+msgstr "The package is not pinned to any version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:178
+msgid "web_ui.items.single_version.pinned_repo_local"
+msgstr "The package is pinned to only use locally installed versions."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:181
+msgid "web_ui.items.single_version.pinned_repo_{}"
+msgstr "The package is pinned to only use versions from repository '{}'."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:192
+msgid "web_ui.items.single_version.pin_local_repo_button"
+msgstr "Pin to local packages"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:197
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:210
+msgid "web_ui.items.single_version.pin_repo_button"
+msgstr "Pin to repository"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:204
+msgid "web_ui.items.single_version.repin_repo_button"
+msgstr "Pin to this repository"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:218
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:229
+msgid "web_ui.items.single_version.pin_ver_button"
+msgstr "Pin to this version"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:221
+msgid "web_ui.items.single_version.pinned_ver"
+msgstr "The package is pinned to this version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:224
+msgid "web_ui.items.single_version.repin_ver_button"
+msgstr "Pin to this version"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:226
+msgid "web_ui.items.single_version.pinned_other_ver"
+msgstr "The package is pinned to a different version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:234
+msgid "web_ui.items.single_version.active_ver_is_this_one"
+msgstr "This is the currently active version."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:238
+msgid "web_ui.items.single_version.active_ver_is_{}"
+msgstr "Currently active version is '{}'."
+
+#: src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja:23
+msgid "web_ui.packages.title"
+msgstr "Packages"
+
+#: src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja:40
+msgid "web_ui.packages.heading"
+msgstr "Available packages"
+
+#: src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja:76
+msgid "web_ui.packages.enabled_version_{}"
+msgstr "enabled version {}"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:23
+msgid "web_ui.landing.title"
+msgstr "Landing page"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:27
+msgid "web_ui.landing.heading.haketilo_is_running"
+msgstr "Haketilo is running"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:31
+msgid "web_ui.landing.web_ui.landing.what_to_do_1"
+msgstr ""
+"In order to access web pages through Haketilo, make sure your browser is "
+"configured to use it as a proxy for both HTTP and HTTPs. Please use the "
+"following values."
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:34
+msgid "web_ui.landing.host_label"
+msgstr "Address"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:40
+msgid "web_ui.landing.port_label"
+msgstr "Port"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:47
+msgid "web_ui.landing.html.what_to_do_2"
+msgstr ""
+"If you've configured your browser properly, you can visit <a "
+"href=\"http://hkt.mitm.it\">http://hkt.mitm.it</a>. It's Haketilo "
+"configuration page that's hosted locally &quot;inside&quot; the proxy."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:24
+msgid "web_ui.prompts.auto_install_error.title"
+msgstr "Installation failure"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:29
+msgid "web_ui.err.retry_install.file_installation_error"
+msgstr ""
+"Another failure occured when retrying to install needed items from "
+"repository."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:33
+msgid "web_ui.err.retry_install.repo_communication_error"
+msgstr "Another failure occured when retrying to communicate with repository."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:37
+msgid "web_ui.prompts.auto_install_error.heading"
+msgstr "Installation failure"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:42
+msgid "web_ui.prompts.auto_install_error.package_{}_failed_to_install"
+msgstr ""
+"Automatically activated package '{}' failed to install because Haketilo "
+"couldn't fetch package files from its repository server. Please verify "
+"that you do have network connection and try again. You can also choose to"
+" permanently disable the package."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:47
+msgid "web_ui.prompts.auto_install_error.disable_button"
+msgstr "Disable"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:48
+msgid "web_ui.prompts.auto_install_error.retry_button"
+msgstr "Retry installation"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:25
+msgid "web_ui.prompts.package_suggestion.title"
+msgstr "Package suggestion"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:38
+msgid "web_ui.prompts.package_suggestion.heading"
+msgstr "Package suitable for current site was found"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:43
+msgid "web_ui.prompts.package_suggestion.do_you_want_to_enable_package_{}"
+msgstr ""
+"Do you want to enable package '{}'? It will then be used whenever you "
+"visit this site."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:48
+msgid "web_ui.prompts.package_suggestion.disable_button"
+msgstr "Disable"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:49
+msgid "web_ui.prompts.package_suggestion.enable_button"
+msgstr "Enable"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:23
+msgid "web_ui.repos.add.title"
+msgstr "New repository"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:27
+msgid "web_ui.repos.add.heading"
+msgstr "Configure a new repository"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:32
+msgid "web_ui.repos.add.name_field_label"
+msgstr "Name"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:34
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:68
+msgid "web_ui.err.repo_name_invalid"
+msgstr "Chosen name is not valid."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:37
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:72
+msgid "web_ui.err.repo_name_taken"
+msgstr "Chosen name is already in use."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:41
+msgid "web_ui.repos.add.url_field_label"
+msgstr "URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:43
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:116
+msgid "web_ui.err.repo_url_invalid"
+msgstr "Chosen URL is not valid."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:49
+msgid "web_ui.repos.add.submit_button"
+msgstr "Add repository"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:23
+msgid "web_ui.repos.title"
+msgstr "Repositories"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:33
+msgid "web_ui.repos.heading"
+msgstr "Manage repositories"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:39
+msgid "web_ui.repos.add_repo_button"
+msgstr "Configure new repository"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:44
+msgid "web_ui.repos.repo_list_heading"
+msgstr "Configured repositories"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:67
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:82
+msgid "web_ui.repos.package_count_{}"
+msgstr "packages: {}"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:79
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:47
+msgid "web_ui.repos.local_packages_semirepo"
+msgstr "Local items"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:23
+msgid "web_ui.repos.single.title"
+msgstr "Repository view"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:43
+msgid "web_ui.err.repo_api_version_unsupported"
+msgstr ""
+"Repository uses an unsupported API version. You might need to update "
+"Haketilo."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:50
+msgid "web_ui.repos.single.heading.name_{}"
+msgstr "Repository '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:53
+msgid "web_ui.repos.single.name_label"
+msgstr "Name"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:59
+msgid "web_ui.repos.single.update_name_button"
+msgstr "Change name"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:82
+msgid "web_ui.repos.single.no_update_name_button"
+msgstr "Cancel"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:86
+msgid "web_ui.repos.single.commit_update_name_button"
+msgstr "Set new name"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:97
+msgid "web_ui.repos.single.repo_is_deleted"
+msgstr ""
+"This repository has been deleted but you're still holding packages that "
+"came from it."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:102
+msgid "web_ui.repos.single.url_label"
+msgstr "URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:108
+msgid "web_ui.repos.single.update_url_button"
+msgstr "Change URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:124
+msgid "web_ui.repos.single.no_update_url_button"
+msgstr "Cancel"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:128
+msgid "web_ui.repos.single.commit_update_url_button"
+msgstr "Set new URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:135
+msgid "web_ui.repos.single.last_refreshed_label"
+msgstr "Last refreshed on"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:139
+msgid "web_ui.repos.single.repo_never_refreshed"
+msgstr "This repository has not been refreshed yet"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:148
+msgid "web_ui.repos.single.stats_label"
+msgstr "Statistics"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:153
+msgid "web_ui.repos.item_count_{mappings}_{resources}"
+msgstr "packages: {mappings}; libraries: {resources}"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:161
+msgid "web_ui.repos.item_count_{mappings}"
+msgstr "packages: {mappings}"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:171
+msgid "web_ui.repos.single.actions_label"
+msgstr "Actions"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:173
+msgid "web_ui.repos.single.remove_button"
+msgstr "Remove repository"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:174
+msgid "web_ui.repos.single.refresh_button"
+msgstr "Refresh"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:23
+msgid "web_ui.rules.add.title"
+msgstr "New rule"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:27
+msgid "web_ui.rules.add.heading"
+msgstr "Define a new rule"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:32
+msgid "web_ui.rules.add.pattern_field_label"
+msgstr "URL pattern"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:35
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:56
+msgid "web_ui.err.rule_pattern_invalid"
+msgstr "Chosen URL pattern is not valid."
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:40
+msgid "web_ui.rules.add.block_or_allow_label"
+msgstr "Page's JavaScript treatment"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:44
+msgid "web_ui.rules.add.block_label"
+msgstr "block"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:49
+msgid "web_ui.rules.add.allow_label"
+msgstr "allow"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:56
+msgid "web_ui.rules.add.submit_button"
+msgstr "Add rule"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:23
+msgid "web_ui.rules.title"
+msgstr "Script blocking"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:33
+msgid "web_ui.rules.heading"
+msgstr "Manage script blocking"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:39
+msgid "web_ui.rules.add_rule_button"
+msgstr "Define new rule"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:44
+msgid "web_ui.rules.rule_list_heading"
+msgstr "Defined rules"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:23
+msgid "web_ui.rules.single.title"
+msgstr "Rule view"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:36
+msgid "web_ui.rules.single.heading.allow"
+msgstr "Script allowing rule"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:38
+msgid "web_ui.rules.single.heading.block"
+msgstr "Script blocking rule"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:42
+msgid "web_ui.rules.single.pattern_label"
+msgstr "URL pattern"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:48
+msgid "web_ui.rules.single.update_pattern_button"
+msgstr "Change URL pattern"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:66
+msgid "web_ui.rules.single.no_update_pattern_button"
+msgstr "Cancel"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:70
+msgid "web_ui.rules.single.commit_update_pattern_button"
+msgstr "Set new pattern"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:77
+msgid "web_ui.rules.single.block_or_allow_label"
+msgstr "Rule function"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:82
+msgid "web_ui.rules.single.allow_button"
+msgstr "Allow JavaScript"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:83
+msgid "web_ui.rules.single.block_button"
+msgstr "Block JavaScript"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:101
+msgid "web_ui.rules.single.actions_label"
+msgstr "Actions"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:103
+msgid "web_ui.rules.single.remove_button"
+msgstr "Remove rule"
+
+#: src/hydrilla/proxy/web_ui/templates/web_ui_base.html.jinja:20
+msgid "web_ui.base.title.haketilo_proxy"
+msgstr "Haketilo"
+
+#: src/hydrilla/server/malcontent.py:77
+msgid "err.server.malcontent_path_not_dir_{}"
+msgstr "Provided 'malcontent_dir' path does not name a directory: {}"
+
+#: src/hydrilla/server/malcontent.py:96
+msgid "err.server.couldnt_load_item_from_{}"
+msgstr "Couldn't load item from {}."
+
+#: src/hydrilla/server/malcontent.py:109
+msgid "err.server.no_file_{required_by}_{ver}_{file}_{sha256}"
+msgstr ""
+"'{required_by}', version '{ver}' uses a file named {file} with SHA256 "
+"hash of {sha256}, but the file is missing."
+
+#: src/hydrilla/server/malcontent.py:133
+msgid "err.server.item_{item}_in_file_{file}"
+msgstr "Item {item} incorrectly present under {file}."
+
+#: src/hydrilla/server/malcontent.py:139
+msgid "item_version_{ver}_in_file_{file}"
+msgstr "Item version {ver} incorrectly present under {file}."
+
+#: src/hydrilla/server/malcontent.py:166
+msgid "err.server.no_dep_{resource}_{ver}_{dep}"
+msgstr "Unknown dependency '{dep}' of resource '{resource}', version '{ver}'."
+
+#: src/hydrilla/server/malcontent.py:181
+msgid "err.server.no_payload_{mapping}_{ver}_{payload}"
+msgstr "Unknown payload '{payload}' of mapping '{mapping}', version '{ver}'."
+
+#: src/hydrilla/server/malcontent.py:196
+msgid "err.server.no_mapping_{required_by}_{ver}_{required}"
+msgstr "Unknown mapping '{required}' required by '{required_by}', version '{ver}'."
+
+#: src/hydrilla/server/malcontent.py:224
+msgid "server.err.couldnt_register_{mapping}_{ver}_{pattern}"
+msgstr ""
+"Couldn't register mapping '{mapping}', version '{ver}' (pattern "
+"'{pattern}')."
+
+#: src/hydrilla/server/serve.py:81
+msgid "err.server.opt_hydrilla_parent_not_implemented"
+msgstr ""
+"Hydrilla was told to connect to a parent Hydrilla server but this feature"
+" is not yet implemented."
+
+#: src/hydrilla/server/serve.py:217
+msgid "serve_hydrilla_packages_explain_wsgi_considerations"
+msgstr ""
+"Serve Hydrilla packages.\n"
+"\n"
+"This command is meant to be a quick way to run a local or development "
+"Hydrilla instance. For better performance, consider deployment using "
+"WSGI."
+
+#: src/hydrilla/server/serve.py:220
+msgid "directory_to_serve_from_overrides_config"
+msgstr ""
+"Directory to serve files from. Overrides value from the config file (if "
+"any)."
+
+#: src/hydrilla/server/serve.py:222
+msgid "project_url_to_display_overrides_config"
+msgstr ""
+"Project url to display on generated HTML pages. Overrides value from the "
+"config file (if any)."
+
+#: src/hydrilla/server/serve.py:224
+msgid "tcp_port_to_listen_on_overrides_config"
+msgstr ""
+"TCP port number to listen on (0-65535). Overrides value from the config "
+"file (if any)."
+
+#: src/hydrilla/server/serve.py:227
+msgid "path_to_config_file_explain_default"
+msgstr ""
+"Path to Hydrilla server configuration file (optional, by default Hydrilla"
+" loads its own config file, which in turn tries to load "
+"/etc/hydrilla/config.json)."
+
+#: src/hydrilla/server/serve.py:259
+msgid "config_option_{}_not_supplied"
+msgstr "Missing configuration option '{}'."
+
+#: src/hydrilla/server/serve.py:263
+msgid "serve_hydrilla_packages_wsgi_help"
+msgstr ""
+"Serve Hydrilla packages.\n"
+"\n"
+"This program is a WSGI script that runs Hydrilla repository behind an "
+"HTTP server like Apache2 or Nginx. You can configure Hydrilla through the"
+" /etc/hydrilla/config.json file."
+
+#: src/hydrilla/url_patterns.py:127
+msgid "err.url_pattern_{}.bad"
+msgstr "Not a valid Haketilo URL pattern: {}"
+
+#: src/hydrilla/url_patterns.py:130
+msgid "err.url_{}.bad"
+msgstr "Not a valid URL: {}"
+
+#: src/hydrilla/url_patterns.py:137
+msgid "err.url_pattern_{}.bad_scheme"
+msgstr "URL pattern has an unknown scheme: {}"
+
+#: src/hydrilla/url_patterns.py:140
+msgid "err.url_{}.bad_scheme"
+msgstr "URL has an unknown scheme: {}"
+
+#: src/hydrilla/url_patterns.py:145
+msgid "err.url_pattern_{}.special_scheme_port"
+msgstr "URL pattern has an explicit port while it shouldn't: {}"
+
+#: src/hydrilla/url_patterns.py:157
+msgid "err.url_pattern_{}.bad_port"
+msgstr "URL pattern has a port outside of allowed range (1-65535): {}"
+
+#: src/hydrilla/url_patterns.py:160
+msgid "err.url_{}.bad_port"
+msgstr "URL has a port outside of allowed range (1-65535): {}"
+
+#: src/hydrilla/url_patterns.py:181
+msgid "err.url_pattern_{}.has_query"
+msgstr "URL pattern has a query string while it shouldn't: {}"
+
+#: src/hydrilla/url_patterns.py:185
+msgid "err.url_pattern_{}.has_frag"
+msgstr "URL pattern has a fragment string while it shouldn't: {}"
+
diff --git a/src/hydrilla/locales/pl_PL/LC_MESSAGES/messages.po b/src/hydrilla/locales/pl_PL/LC_MESSAGES/messages.po
new file mode 100644
index 0000000..2834afe
--- /dev/null
+++ b/src/hydrilla/locales/pl_PL/LC_MESSAGES/messages.po
@@ -0,0 +1,1541 @@
+# SPDX-License-Identifier: CC0-1.0
+# English translations for Hydrilla&Haketilo.
+#
+# Copyright (C) 2021-2022 Wojtek Kosior <koszko@koszko.org>
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+msgid ""
+msgstr ""
+"Project-Id-Version: hydrilla 3.0-beta2\n"
+"Report-Msgid-Bugs-To: koszko@koszko.org\n"
+"POT-Creation-Date: 2022-11-23 19:21+0100\n"
+"PO-Revision-Date: 2022-02-12 00:00+0000\n"
+"Last-Translator: Wojtek Kosior <koszko@koszko.org>\n"
+"Language: pl_PL\n"
+"Language-Team: pl_PL <koszko@koszko.org>\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.9.0\n"
+
+#: src/hydrilla/builder/build.py:81 src/hydrilla/builder/local_apt.py:120
+#: src/hydrilla/builder/local_apt.py:426
+msgid "couldnt_execute_{}_is_it_installed"
+msgstr ""
+"Nie można wykonać '{}'. Czy narzędzie jest zainstalowane i osiągalne "
+"przez zmiennÄ… PATH?"
+
+#: src/hydrilla/builder/build.py:85 src/hydrilla/builder/local_apt.py:124
+#: src/hydrilla/builder/local_apt.py:430
+msgid "command_{}_failed"
+msgstr "Następująca komenda zakończyła wykonanie z niezerowym statusem wyjścia: {}"
+
+#: src/hydrilla/builder/build.py:201
+msgid "path_contains_double_dot_{}"
+msgstr ""
+"Próba załodowania ścieżki '{}', która zawiera niedozwolone odwołanie do "
+"katalogu nadrzędnego ('..')."
+
+#: src/hydrilla/builder/build.py:210
+msgid "loading_{}_outside_package_dir"
+msgstr ""
+"Próba załodowania ścieżki '{}', która leża poza katalogiem źródłowym "
+"projektu."
+
+#: src/hydrilla/builder/build.py:214
+msgid "loading_reserved_index_json"
+msgstr "Próba załadowania pliku z zarezerwowaną nazwą 'index.json'."
+
+#: src/hydrilla/builder/build.py:221
+msgid "referenced_file_{}_missing"
+msgstr "Brak pliku '{}', do którego nastąpiło odwołanie."
+
+#: src/hydrilla/builder/build.py:412
+msgid "report_spdx_not_in_copyright_list"
+msgstr ""
+"Ma zostać wygenerowany 'report.spdx' ale 'report.spdx' nie jest na liście"
+" plików z danymi prawnoautorskimi. Nie można kontynuować."
+
+#: src/hydrilla/builder/build.py:489
+msgid "build_package_from_srcdir_to_dstdir"
+msgstr "Wybuduj pakiet spod `scrdir` i zapisz wyjściowe pliki pod `dstdir`."
+
+#: src/hydrilla/builder/build.py:491
+msgid "source_directory_to_build_from"
+msgstr "Katalog ze źródłowym pakietem do zbudowania."
+
+#: src/hydrilla/builder/build.py:493
+msgid "path_instead_of_index_json"
+msgstr ""
+"Ścieżka do pliku, który ma być przetworzony zamiast pliku index.json "
+"(jeśli nie jest absolutna, jest rozwiązywana względnie do `srcdir`)."
+
+#: src/hydrilla/builder/build.py:495
+msgid "path_instead_for_piggyback_files"
+msgstr ""
+"Ścieżka do niestandardowego katalogu z archiwami obcych pakietów do "
+"użycia."
+
+#: src/hydrilla/builder/build.py:497
+msgid "built_package_files_destination"
+msgstr ""
+"Katalog wyjściowy, pod którym zapisane mają być pliki wyjściowe "
+"zbudowanych pakietów."
+
+#: src/hydrilla/builder/build.py:499
+#: src/hydrilla/mitmproxy_launcher/launch.py:66
+#: src/hydrilla/server/serve.py:211 src/hydrilla/server/serve.py:229
+#: src/hydrilla/server/serve.py:269
+#, python-format
+msgid "%(prog)s_%(version)s_license"
+msgstr ""
+"%(prog)s %(version)s\n"
+"Copyright (C) 2021,2022 Wojtek Kosior i współpracownicy.\n"
+"Licencja AGPLv3+: GNU AGPL wersja 3 lub późniejsza "
+"<https://gnu.org/licenses/gpl.html>\n"
+"To jest wolne oprogramowanie; masz prawo je zmieniać i rozpowszechniać.\n"
+"Brak JAKIEJKOLWIEK GWARANCJI, w stopniu dozwolonym przez prawo."
+
+#: src/hydrilla/builder/build.py:500 src/hydrilla/server/serve.py:230
+#: src/hydrilla/server/serve.py:270
+msgid "version_printing"
+msgstr "Wypisz informacji o wersji i zakończ."
+
+#: src/hydrilla/builder/common_errors.py:58
+msgid "STDOUT_OUTPUT_heading"
+msgstr "## Standardowe wyjście komendy ##"
+
+#: src/hydrilla/builder/common_errors.py:61
+msgid "STDERR_OUTPUT_heading"
+msgstr "## Standardowe wyjście błędu komendy ##"
+
+#: src/hydrilla/builder/local_apt.py:153
+msgid "distro_{}_unknown"
+msgstr "Próba użycia nieznanej dystrybucji oprogramowania '{}'."
+
+#: src/hydrilla/builder/local_apt.py:197
+msgid "couldnt_import_{}_is_it_installed"
+msgstr ""
+"Nie udało się zaimportować '{}'. Czy moduł jest zainstalowany i widzialny"
+" dla tej instancji Python'a?"
+
+#: src/hydrilla/builder/local_apt.py:205
+msgid "gpg_couldnt_recv_key_{}"
+msgstr "Nie udało się zaimportować klucza PGP '{}'."
+
+#: src/hydrilla/builder/local_apt.py:325
+msgid "apt_install_output_not_understood"
+msgstr "Informacje na wyjściu komendy 'apt-get install' nie zostały zrozumiane."
+
+#: src/hydrilla/builder/local_apt.py:351
+msgid "apt_download_gave_bad_filename_{}"
+msgstr ""
+"Komenda 'apt-get download' wygenerowała plik o niespodziewanej nazwie "
+"'{}'."
+
+#: src/hydrilla/builder/piggybacking.py:109
+msgid "loading_{}_outside_piggybacked_dir"
+msgstr ""
+"Próba załadowania ścieżki '{}', która leży poza katalogiem głównym "
+"wykorzystanych obcych pakietów."
+
+#: src/hydrilla/item_infos.py:88
+msgid "err.item_info.filename_invalid_{}"
+msgstr "Definicja elementu zawiera niedozwoloną ścieżkę: {}"
+
+#: src/hydrilla/item_infos.py:511
+#, python-brace-format
+msgid "uuid_mismatch_{identifier}"
+msgstr "Dla elementu '{identifier}' zostały sprecyzowane dwa różne uuid."
+
+#: src/hydrilla/json_instances.py:84
+msgid "bad_json_comment_line_{line_num}_char_{char_num}"
+msgstr ""
+"Dokument JSON zawiera nieprawidłowy komentarz w lini {line_num}, znak "
+"{char_num}."
+
+#: src/hydrilla/json_instances.py:135
+msgid "unknown_schema_{}"
+msgstr ""
+"Dokument JSON document deklaruje swój schemat jako '{}'. Jest to nieznany"
+" schemat."
+
+#: src/hydrilla/json_instances.py:186
+msgid "err.util.text_in_{}_not_valid_json"
+msgstr "Nie prawidłowy plik JSON: {}"
+
+#: src/hydrilla/json_instances.py:189
+msgid "err.util.text_not_valid_json"
+msgstr "Nie prawidłowy plik JSON."
+
+#: src/hydrilla/json_instances.py:204
+msgid "no_schema_number_in_instance"
+msgstr "Brak numeru wersji schematu dokumentu JSON."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:55
+msgid "cli_help.haketilo"
+msgstr ""
+"Uruchom proxy Haketilo.\n"
+"\n"
+"Ta komenda uruchamia Haketilo jako lokalne proxy HTTP, które może być "
+"następnie wykorzystane przez przeglądarkę internetową."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:57
+msgid "cli_opt.haketilo.listen_host"
+msgstr "Adres IP, na ktrym proxy powinno nasłuchiwać."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:59
+msgid "cli_opt.haketilo.port"
+msgstr "Numer portu TCP, na którym proxy powinno nasłuchiwać."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:61
+msgid "cli_opt.haketilo.launch_browser"
+msgstr ""
+"Czy Haketilo powinno spróbować otworzyć swoją stronę lądowania w Twojej "
+"domyślnej przeglądarce. Domyślnie tak ('-L')."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:64
+msgid "cli_opt.haketilo.dir_defaults_to_{}"
+msgstr "Katalog danych do użycia przez Haketilo. Domyślnie \"{}\"."
+
+#: src/hydrilla/mitmproxy_launcher/launch.py:67
+msgid "cli_opt.haketilo.version"
+msgstr "Wypisz informacji o wersji i zakończ."
+
+#: src/hydrilla/proxy/addon.py:195
+msgid "warn.proxy.setting_already_configured_{}"
+msgstr ""
+"Próbowano skonfigurować opcję '{}' rozszerzenia do mitmproxy, która "
+"została już skonfigurowana."
+
+#: src/hydrilla/proxy/addon.py:230
+msgid "warn.proxy.couldnt_launch_browser"
+msgstr ""
+"Nie udało się otworzyć adresu w przeglądarce internetowej. Czy masz "
+"skonfigurowaną domyślną przeglądarkę?"
+
+#: src/hydrilla/proxy/addon.py:271
+msgid "err.proxy.unknown_error_{}_try_again"
+msgstr ""
+"Wystąpił błąd w Haketilo. Spróbuj ponownie.\n"
+"\n"
+"{}"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:39
+msgid "info.base.title"
+msgstr "Informacje o stronie"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:44
+msgid "info.base.heading.page_info"
+msgstr "Szczegóły obsługiwania strony przez Haketilo"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:48
+msgid "info.base.page_url_label"
+msgstr "URL strony"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:56
+msgid "info.base.page_policy_label"
+msgstr "Aktywna polityka"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:70
+msgid "info.base.more_config_options_label"
+msgstr "Konfiguruj"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:78
+msgid "info.base.this_site_script_blocking_button"
+msgstr "Blokowanie JS'a na tej witrynie"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:81
+msgid "info.base.this_site_payload_button"
+msgstr "Modyfikator dla stron na tej witrynie"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:84
+msgid "info.base.this_page_script_blocking_button"
+msgstr "Blokowanie JS'a na tej stronie"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja:87
+msgid "info.base.this_page_payload_button"
+msgstr "Modyfikator dla tej strony"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja:13
+msgid "info.js_error_blocked.html"
+msgstr ""
+"Wystąpił błąd w Haketilo podczas wybierania polityki działania dla tej "
+"strony. Dla bezpieczeństwa Haketilo będzie blokować JavaScript na "
+"stronach, w przypadku których tak sie dzieje. Takie zdarzenie nie powinno"
+" mieć miejsca, rozważ <a href=\"mailto:koszko@koszko.org\">zgłoszenie "
+"błędu</a>."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja:18
+msgid "info.js_error_blocked.stacktrace"
+msgstr "Szczegóły błędu"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_fallback_allowed_info.html.jinja:13
+msgid "info.js_fallback_allowed"
+msgstr "JavaScript może się wykonywać na tej stronie. Jest to domyślna polityka."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_fallback_blocked_info.html.jinja:13
+msgid "info.js_fallback_blocked"
+msgstr ""
+"Wykonanie JavaScript'u na tej stronie jest zablokowane. Jest to domyślna "
+"polityka."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_rule_allowed_info.html.jinja:13
+msgid "info.js_allowed.html.rule{url}_is_used"
+msgstr ""
+"JavaScript może się wykonywać na tej stronie. <a href=\"{url}\" "
+"target=\"_blank\">Reguła pozwalająca</a> została zkonfigurowana przez "
+"użytkownika."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_rule_blocked_info.html.jinja:13
+msgid "info.js_blocked.html.rule{url}_is_used"
+msgstr ""
+"Wykonanie JavaScript'u na tej stronie jest zablokowane. A <a "
+"href=\"{url}\" target=\"_blank\">reguła zabraniająca</a> została "
+"skonfigurowana przez użytkownika."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/js_rule_info.html.jinja:32
+msgid "info.rule.matched_pattern_label"
+msgstr "Dopasowany wzorzec reguły"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja:36
+msgid "info.payload.html.package_{identifier}{url}_is_used"
+msgstr ""
+"Ta strona jest obsługiwana przez pakiet o nazwie '<a href=\"{url}\" "
+"target=\"_blank\">{identifier}</a>'. Pakiet został skonfigurowany przez "
+"użytkownika i może dokonywać zmian na stronie."
+
+#: src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja:43
+msgid "info.payload.matched_pattern_label"
+msgstr "Dopasowany wzorzec pakietu"
+
+#: src/hydrilla/proxy/policies/info_pages_templates/special_page_info.html.jinja:13
+msgid "info.special_page"
+msgstr ""
+"To jest specjalna strona. Nie mają na nią wpływu polityki stosowane "
+"normalnie przez Haketilo."
+
+#: src/hydrilla/proxy/policies/payload_resource.py:249
+msgid "api.file_not_found"
+msgstr "Żądany plik nie został znaleziony."
+
+#: src/hydrilla/proxy/policies/payload_resource.py:365
+msgid "api.resource_not_enabled_for_access"
+msgstr "Żądany zasób nie jest udostępniony."
+
+#: src/hydrilla/proxy/state_impl/concrete_state.py:127
+msgid "err.proxy.unknown_db_schema"
+msgstr ""
+"Dane Haketilo zostały zmodyfikowane, prawdopodobnie przez nowszą wersję "
+"Haketilo."
+
+#: src/hydrilla/proxy/state_impl/concrete_state.py:161
+msgid "err.proxy.no_sqlite_foreign_keys"
+msgstr ""
+"Ta instalacja Haketilo używa wersji SQLite, które nie wspiera ograniczeń "
+"kluczy obcych."
+
+#: src/hydrilla/proxy/state_impl/concrete_state.py:326
+msgid "warn.proxy.failed_to_register_landing_page_at_{}"
+msgstr "Nie udało się zarejestrować strony lądowania pod \"{}\"."
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:82
+msgid "web_ui.base.nav.home"
+msgstr "Start"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:83
+msgid "web_ui.base.nav.rules"
+msgstr "Blokowanie skryptów"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:84
+msgid "web_ui.base.nav.packages"
+msgstr "Pakiety"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:85
+msgid "web_ui.base.nav.libraries"
+msgstr "Biblioteki"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:86
+msgid "web_ui.base.nav.repos"
+msgstr "Repozytoria"
+
+#: src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja:87
+msgid "web_ui.base.nav.import"
+msgstr "Importuj"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:23
+msgid "web_ui.import.title"
+msgstr "Import elementów"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:41
+msgid "web_ui.import.heading"
+msgstr "Import elementów"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:43
+msgid "web_ui.import.heading_import_from_file"
+msgstr "Z pliku ZIP"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:49
+msgid "web_ui.err.uploaded_file_not_zip"
+msgstr "Nadesłany plik nie jest poprawnym archiwum ZIP."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:53
+msgid "web_ui.err.invalid_uploaded_malcontent"
+msgstr "Nadesłane archiwum nie zawiera poprawnego katalogu pakietów Haketilo."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:61
+msgid "web_ui.import.choose_zipfile_button"
+msgstr "Wybierz plik"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:68
+msgid "web_ui.import.install_from_file_button"
+msgstr "Importuj z wybranego pliku"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:76
+msgid "web_ui.import.heading_import_ad_hoc"
+msgstr "Ad hoc"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:81
+msgid "web_ui.err.invalid_ad_hoc_package"
+msgstr "Importowany pakiet ad hoc zawiera błędy."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:87
+msgid "web_ui.import.identifier_field_label"
+msgstr "Identyfikator"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:89
+msgid "web_ui.err.invalid_ad_hoc_identifier"
+msgstr "Wybrany identyfikator jest niepoprawny."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:93
+msgid "web_ui.import.long_name_field_label"
+msgstr "DÅ‚uga nazwa (opcjonalna)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:96
+msgid "web_ui.import.version_field_label"
+msgstr "Wersja (opcjonalna)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:98
+msgid "web_ui.err.invalid_ad_hoc_version"
+msgstr "Wybrana wersja jest niepoprawna."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:102
+msgid "web_ui.import.description_field_label"
+msgstr "Opic (opcjonalny)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:105
+msgid "web_ui.import.patterns_field_label"
+msgstr "Wzorce URL (jeden na każdej lini)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:109
+msgid "web_ui.err.invalid_ad_hoc_patterns"
+msgstr "Wybrane wzorce sÄ… niepoprawne."
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:113
+msgid "web_ui.import.script_text_field_label"
+msgstr "JavaScript do wykonanie na stronach, które pasują do jednego ze wzorców"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:116
+msgid "web_ui.import.lic_text_field_label"
+msgstr "Tekst licencji pakietu (opcjonalny)"
+
+#: src/hydrilla/proxy/web_ui/templates/import.html.jinja:121
+msgid "web_ui.import.install_ad_hoc_button"
+msgstr "Dodaj nowy pakiet"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:23
+msgid "web_ui.home.title"
+msgstr "Witaj"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:35
+#: src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja:44
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:30
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:35
+msgid "web_ui.err.file_installation_error"
+msgstr "Nie udało się zainstalować potrzebnych elementów z repozytorium."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:39
+#: src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja:48
+msgid "web_ui.err.impossible_situation_error"
+msgstr "Ograniczenia własne elementów uniemożliły wykonanie akcji."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:43
+msgid "web_ui.home.heading.welcome_to_haketilo"
+msgstr "Witaj w Haketilo!"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:47
+msgid "web_ui.home.this_is_haketilo_page"
+msgstr ""
+"To jest wirtualna witryna hostowana lokalnie przez Haketilo. Możesz użyć "
+"jej do skonfigurowania proxy Haketilo."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:53
+msgid "web_ui.home.heading.about_haketilo"
+msgstr "O narzędziu"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:57
+msgid "web_ui.home.html.haketilo_is_blah_blah"
+msgstr ""
+"Haketilo to narządzie, które daje użytkownikom więcej kontroli nad "
+"przeglądaniem stron internetowych. Może blokować niechciane programy "
+"JavaScript na stronach, jak i dodawać do stron spersonalizowaną logikę. "
+"Haketilo było pierwotnie rozszerzeniem przeglądarkowym, po czym zostało "
+"utworzone na nowo jako proxy HTTP. Jest zbudowane na popularnym <a "
+"href=\"https://mitmproxy.org/\">mitmproxy</a>."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:61
+msgid "web_ui.home.html.see_haketilo_doc_{url}"
+msgstr ""
+"Pomocne informacje dotyczące użycia tego narządzia można znaleźć we <a "
+"href=\"{url}\">wbudowanej dokumentacji</a> Haketilo."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:70
+msgid "web_ui.home.heading.configuring_browser_for_haketilo"
+msgstr "Konfiguracja przeglÄ…darki pod Haketilo"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:74
+msgid "web_ui.home.html.to_add_certs_do_xyz"
+msgstr ""
+"Proxy Haketilo działa modyfikując dane wymieniane przez przeglądarkę z "
+"serwerami sieci WWW. Nie powoduje to żadnych problemów w przypadku "
+"adresów http://. Jednak w przypadku adresów https:// transmitowane dane "
+"są chronione przed modyfikacją przez użycie kryptografii. Żeby Twoja "
+"przeglądarka mogła zaufać danym zmodyfikowanym przez Haketilo, musi być "
+"poinstruowana, że ma respektować certyfikat kryptograficzny wystawiony "
+"przez proxy. Jeśli jeszcze tego nie zrobiłeś/aś, pobierz certyfikat z <a "
+"href=\"http://mitm.it\">tej strony</a> i dodaj go do swojego systemu "
+"operacyjnego, przeglÄ…darki lub obydwu."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:81
+msgid "web_ui.home.heading.options"
+msgstr "Opcje globalne"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:84
+msgid "web_ui.home.choose_language_label"
+msgstr "Wybierz swój język"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:103
+msgid "web_ui.home.mapping_usage_mode_label"
+msgstr "Tryb używania pakietów"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:114
+msgid "web_ui.home.packages_are_used_when_enabled"
+msgstr ""
+"Haketilo jest obecnie skonfigurowane tak, aby używać wyłącznie pakietów, "
+"które użytkownik sam aktywował."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:117
+msgid "web_ui.home.user_gets_asked_whether_to_enable_package"
+msgstr ""
+"Haketilo jest obecnie skonfigurowane tak, aby pytać zawsze, kiedy "
+"zostanie znaleziony pakiet, który mógłby być użyty na odwiedzanej "
+"stronie."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:121
+msgid "web_ui.home.packages_are_used_automatically"
+msgstr ""
+"Haketilo jest obecnie skonfigurowane tak, żeby automatycznie używać "
+"pakietów, które są dostępne dla odwiedzanej strony."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:128
+msgid "web_ui.home.use_enabled_button"
+msgstr "Używaj aktywowanych"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:131
+msgid "web_ui.home.use_question_button"
+msgstr "Pytaj, czy użyć"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:134
+msgid "web_ui.home.use_auto_button"
+msgstr "Używaj automatycznie"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:141
+msgid "web_ui.home.script_blocking_mode_label"
+msgstr "Domyślne traktowanie skryptów"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:151
+msgid "web_ui.home.scripts_are_allowed_by_default"
+msgstr ""
+"Haketilo obecnie domyślnie pozwala na wykonanie JavaScript'u przysyłanego"
+" przez strony."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:154
+msgid "web_ui.home.scripts_are_blocked_by_default"
+msgstr ""
+"Haketilo obecnie domyślnie blokuje wykonanie JavaScript'u przysyłanego "
+"przez strony."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:158
+msgid "web_ui.home.allow_scripts_button"
+msgstr "Pozwalaj"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:159
+msgid "web_ui.home.block_scripts_button"
+msgstr "Blokuj"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:170
+msgid "web_ui.home.advanced_features_label"
+msgstr "Zaawansowane funkcje"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:180
+msgid "web_ui.home.user_is_advanced_user"
+msgstr "Funkcje interfejsu dla zaawansowanych użytkowników są obecnie włączone."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:183
+msgid "web_ui.home.user_is_simple_user"
+msgstr "Funkcje interfejsu dla zaawansowanych użytkowników są obecnie wyłączone."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:190
+msgid "web_ui.home.user_make_advanced_button"
+msgstr "WÅ‚Ä…cz"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:193
+msgid "web_ui.home.user_make_simple_button"
+msgstr "Wyłącz"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:201
+msgid "web_ui.home.update_waiting_label"
+msgstr "Aktualizacje pakietów"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:204
+msgid "web_ui.home.update_is_awaiting"
+msgstr ""
+"Możliwe, że niektóre aktywne elementy mogą być uaktualnione do nowych "
+"wersji."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:207
+msgid "web_ui.home.update_items_button"
+msgstr "Uaktualnij teraz"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:219
+msgid "web_ui.home.orphans_label"
+msgstr "Opuszczone pakiety"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:225
+msgid "web_ui.home.orphans_to_delete_{mappings}"
+msgstr ""
+"Haketilo przechowuje obecnie opuszczone pakiety, które można usunąć "
+"({mappings})."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:229
+msgid "web_ui.home.orphans_to_delete_exist"
+msgstr "Haketilo przechowuje obecnie opuszczone biblioteki, które można usunąć."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:233
+msgid "web_ui.home.orphans_to_delete_{mappings}_{resources}"
+msgstr ""
+"Haketilo przechowuje obecnie opuszczone elementy, które można usunąć "
+"(pakiety: {mappings}; biblioteki: {resources})."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:242
+msgid "web_ui.home.prune_orphans_button"
+msgstr "Wyrzuć opuszczone"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:253
+msgid "web_ui.home.popup_settings_label"
+msgstr "Ustawienia popup'u"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:269
+msgid "web_ui.home.configure_popup_settings_on_pages_with"
+msgstr "Konfiguruj ustawienia popup'u na stronach, gdzie"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:275
+msgid "web_ui.home.popup_settings_jsallowed_button"
+msgstr "JS może się wykonywać"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:276
+msgid "web_ui.home.popup_settings_jsblocked_button"
+msgstr "JS jest zablokowany"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:277
+msgid "web_ui.home.popup_settings_payloadon_button"
+msgstr "Modyfikator w użyciu"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:327
+msgid "web_ui.home.popup_no_button"
+msgstr "Wyłącz popup"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:330
+msgid "web_ui.home.popup_yes_button"
+msgstr "WÅ‚Ä…cz popup"
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:340
+msgid "web_ui.home.jsallowed_popup_yes"
+msgstr ""
+"Haketilo obecnie umożliwia otwieranie okna popup'u na stronach, gdzie "
+"zezwolono na wykonanie oryginalnego JS'a. Jest to dogodność, która "
+"przychodzi za cenę większego ryzyka zarejstrowania unikatowego \"odcisku "
+"przeglÄ…darki\" (tzw. fingerprinting)."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:342
+msgid "web_ui.home.jsallowed_popup_no"
+msgstr ""
+"Haketilo obecnie nie umożliwia otwierania okna popup'u na stronach, gdzie"
+" zezwolono na wykonanie oryginalnego JS'a. To ustawienie jest mniej "
+"dogodne ale zmniejsza ryzyko zarejstrowania unikatowego \"odcisku "
+"przeglÄ…darki\" (tzw. fingerprinting)."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:348
+msgid "web_ui.home.jsblocked_popup_yes"
+msgstr ""
+"Haketilo obecnie umożliwia otwieranie okna popup'u na stronach, gdzie "
+"zablokowano wykonanie oryginalnego JS'a."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:350
+msgid "web_ui.home.jsblocked_popup_no"
+msgstr ""
+"Haketilo obecnie nie umożliwia otwierania okna popup'u na stronach, gdzie"
+" zablokowano wykonanie oryginalnego JS'a."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:356
+msgid "web_ui.home.payloadon_popup_yes"
+msgstr ""
+"Haketilo obecnie umożliwia otwieranie okna popup'u na stronach, gdzie w "
+"użyciu jest modyfikator."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:358
+msgid "web_ui.home.payloadon_popup_no"
+msgstr ""
+"Haketilo obecnie nie umożliwia otwierania okna popup'u na stronach, gdzie"
+" w użyciu jest modyfikator.pages where payload is used."
+
+#: src/hydrilla/proxy/web_ui/templates/index.html.jinja:363
+msgid "web_ui.home.popup_can_be_opened_by"
+msgstr ""
+"Gdy aktywne na danej stronie, okno pupup'u może być otworzone przez "
+"wpisanie wielkich liter \"HKT\". Może być następnie zamknięte przez "
+"kliknięcie gdziekolwiek na ciemnym obszarze naokoło niego."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja:52
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:34
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:39
+msgid "web_ui.err.repo_communication_error"
+msgstr "Nie udało się porozumieć z repozytorium."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:61
+msgid "web_ui.err.item_not_compatible"
+msgstr "Ten element nie jest kompatybilny z obecnÄ… wersjÄ… Haketilo."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:68
+msgid "web_ui.items.single_version.identifier_label"
+msgstr "Identyfikator"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:76
+msgid "web_ui.items.single_version.version_label"
+msgstr "Wersja"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:85
+msgid "web_ui.items.single_version.uuid_label"
+msgstr "UUID"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:95
+msgid "web_ui.items.single_version.description_label"
+msgstr "Opis"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:104
+msgid "web_ui.items.single_version.licenses_label"
+msgstr "Pliki licencji i informacji o prawie autorskim"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:110
+msgid "web_ui.items.single_version.no_license_files"
+msgstr "Brak wyszczególnionych plików z informacjami prawnymi."
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:117
+msgid "web_ui.items.single_version.required_mappings_label"
+msgstr "Potrzebne pakiety"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:137
+msgid "web_ui.items.single_version.min_haketilo_ver_label"
+msgstr "Minimalna wymagana wersja Haketilo"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:147
+msgid "web_ui.items.single_version.max_haketilo_ver_label"
+msgstr "Minimalna dopuszczalna wersja Haketilo"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:164
+msgid "web_ui.items.single_version.install_uninstall_label"
+msgstr "Status instalacji"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:171
+msgid "web_ui.items.single_version.retry_install_button"
+msgstr "Spróbuj ponownie"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:175
+msgid "web_ui.items.single_version.leave_uninstalled_button"
+msgstr "Pozostaw niezainstalowane"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:179
+msgid "web_ui.items.single_version.install_button"
+msgstr "Zainstaluj"
+
+#: src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja:181
+msgid "web_ui.items.single_version.uninstall_button"
+msgstr "Odinstaluj"
+
+#: src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja:23
+msgid "web_ui.libraries.title"
+msgstr "Biblioteki"
+
+#: src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja:40
+msgid "web_ui.libraries.heading"
+msgstr "Dostępne biblioteki"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja:23
+msgid "web_ui.items.single.library.title"
+msgstr "PrzeglÄ…d biblioteki"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja:27
+msgid "web_ui.items.single.library.heading.name_{}"
+msgstr "Biblioteki o nazwie '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja:37
+msgid "web_ui.items.single.library.version_list_heading"
+msgstr "Dostępne wersje"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:24
+msgid "web_ui.items.single_version.library.title"
+msgstr "PrzeglÄ…da wersji bibilioteki"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:30
+msgid "web_ui.items.single_version.library_local.heading.name_{}"
+msgstr "Lokalna biblioteka '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:35
+msgid "web_ui.items.single_version.library.heading.name_{}"
+msgstr "Biblioteka '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:42
+msgid "web_ui.items.single_version.library.install_failed"
+msgstr "Nie udało się zainstalować tej wersji biblioteki."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:46
+msgid "web_ui.items.single_version.library.is_installed"
+msgstr "Biblioteka jest obecnie zainstalowana."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:50
+msgid "web_ui.items.single_version.library.is_not_installed"
+msgstr "Biblioteka jest obecnie niezainstalowana."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:54
+msgid "web_ui.items.single_version.library.version_list_heading"
+msgstr "Inne dostępne wersje tej biblioteki"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:58
+msgid "web_ui.items.single_version.library.scripts_label"
+msgstr "Skrypty"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:64
+msgid "web_ui.items.single_version.library.no_script_files"
+msgstr "Brak plików JavaScript w tej bibliotece."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:71
+msgid "web_ui.items.single_version.library.deps_label"
+msgstr "Zależności"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:86
+msgid "web_ui.items.single_version.library.enabled_label"
+msgstr "Status użycia"
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:90
+msgid "web_ui.items.single_version.library.item_required"
+msgstr "Ta wersja biblioteki jest wymagana przed pewien aktywny pakiet."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:95
+msgid "web_ui.items.single_version.library.item_not_activated"
+msgstr ""
+"Ta wersja biblioteki nie jest wykorzystywana przez żaden aktywowany przez"
+" użytkownika pakiet."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:97
+msgid "web_ui.items.single_version.library.item_will_be_asked_about"
+msgstr ""
+"Ta wersja biblioteki nie jest wykorzystywana przez żaden aktywowany przez"
+" użytkownika pakiet."
+
+#: src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja:100
+msgid "web_ui.items.single_version.library.item_auto_activated"
+msgstr ""
+"Ta wersja biblioteki jest wykorzystywana przez pewien pakiet. Ten pakiet "
+"nie został aktywowany przez użytkownika ale może być aktywowany użyty "
+"automatycznie."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:23
+msgid "web_ui.items.single.package.title"
+msgstr "PrzeglÄ…d pakietu"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:27
+msgid "web_ui.items.single.package.heading.name_{}"
+msgstr "Pakiet o nazwie '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:40
+msgid "web_ui.items.single.package.enabled_label"
+msgstr "Status użycia"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:46
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:117
+msgid "web_ui.items.unenable_button"
+msgstr "Zapomnij"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:47
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:118
+msgid "web_ui.items.disable_button"
+msgstr "Dezaktywuj"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:48
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:119
+msgid "web_ui.items.enable_button"
+msgstr "Aktywuj"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:53
+msgid "web_ui.items.single.package.item_not_enabled"
+msgstr "Pakiet nie został skonfigurowany przez użytkownika."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:56
+msgid "web_ui.items.single.package.item_disabled"
+msgstr "Pakiet został dezaktywowany przez użytkownika."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:60
+msgid "web_ui.items.single.package.item_enabled"
+msgstr "Pakiet został aktywowany przez użytkownika."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:75
+msgid "web_ui.items.single.package.pinning_label"
+msgstr "Przypnij pakiet"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:81
+msgid "web_ui.items.single.package.unpin_button"
+msgstr "Odepnij"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:86
+msgid "web_ui.items.single.package.pin_local_repo_button"
+msgstr "Przypnij do lokalnych pakietów"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:89
+msgid "web_ui.items.single.package.pin_repo_button"
+msgstr "Przypnij do repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:92
+msgid "web_ui.items.single.package.pin_ver_button"
+msgstr "Przypnij do obecnej wersji"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:97
+msgid "web_ui.items.single.package.not_pinned"
+msgstr "Pakiet nie jest przypięty do żadnej wersji."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:101
+msgid "web_ui.items.single.package.pinned_repo_local"
+msgstr "Pakiet jest przypięty - użyte mogą zostać tylko lokalne wersje."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:104
+msgid "web_ui.items.single.package.pinned_repo_{}"
+msgstr ""
+"Pakiet jest przypięty - użyte mogą zostać tylko wersje z repozytorium "
+"'{}'."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:111
+msgid "web_ui.items.single.package.pinned_ver"
+msgstr "Pakiet nie jest przypięty do żadnej konkretnej wersji."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja:126
+msgid "web_ui.items.single.package.version_list_heading"
+msgstr "Dostępne wersje"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:24
+msgid "web_ui.items.single_version.package.title"
+msgstr "PrzeglÄ…d wersji pakietu"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:30
+msgid "web_ui.items.single_version.package_local.heading.name_{}"
+msgstr "Lokalny pakiet '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:35
+msgid "web_ui.items.single_version.package.heading.name_{}"
+msgstr "Pakiet '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:42
+msgid "web_ui.items.single_version.package.install_failed"
+msgstr "Nie udało się zainstalować wersji pakietu."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:46
+msgid "web_ui.items.single_version.package.is_installed"
+msgstr "Pakiet jest obecnie zainstalowany."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:50
+msgid "web_ui.items.single_version.package.is_not_installed"
+msgstr "Pakiet jest obecnie niezainstalowany."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:54
+msgid "web_ui.items.single_version.package.version_list_heading"
+msgstr "Inne dostępne wersje tego pakietu"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:58
+msgid "web_ui.items.single_version.package.payloads_label"
+msgstr "Modyfikatory stron"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:101
+msgid "web_ui.items.single_version.package.no_payloads"
+msgstr "Ten pakiet nie ma modyfikatorów."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:107
+msgid "web_ui.items.single_version.package.enabled_label"
+msgstr "Status użycia"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:128
+msgid "web_ui.items.single_version.package.item_not_activated"
+msgstr "Ten pakiet nie jest aktywny. Ta wersja nie będzie użyta."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:130
+msgid "web_ui.items.single_version.package.item_will_be_asked_about"
+msgstr ""
+"Ten pakiet nie jest aktywny. Zostaniesz zapytany/a, czy aktywować tą "
+"wersję, gdy odwiedzisz witrynę, na której może być użyta."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:133
+msgid "web_ui.items.single_version.package.item_auto_activated"
+msgstr "Ten pakiet nie był aktywowany ale zostanie użyty automatycznie."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:137
+msgid "web_ui.items.single_version.package.item_disabled"
+msgstr "Wszystkie wersje tego pakietu zostały dezaktywowane przez użytkownika."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:141
+msgid "web_ui.items.single_version.package.item_enabled"
+msgstr "Pakiet został aktywowany przez użytkownika."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:156
+msgid "web_ui.items.single_version.package.pinning_label"
+msgstr "Przypinanie aktywnego pakietu"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:168
+msgid "web_ui.items.single_version.unpin_button"
+msgstr "Odepnij"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:173
+msgid "web_ui.items.single_version.not_pinned"
+msgstr "Pakiet nie jest przypięty do żadnej wersji."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:178
+msgid "web_ui.items.single_version.pinned_repo_local"
+msgstr "Pakiet jest przypięty - użyte będą tylko lokalne wersje."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:181
+msgid "web_ui.items.single_version.pinned_repo_{}"
+msgstr "Pakiet jest przypięty - użyte będą tylko wersje z repozytorium '{}'."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:192
+msgid "web_ui.items.single_version.pin_local_repo_button"
+msgstr "Przypnij do pakietów lokalnych"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:197
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:210
+msgid "web_ui.items.single_version.pin_repo_button"
+msgstr "Przypnij do repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:204
+msgid "web_ui.items.single_version.repin_repo_button"
+msgstr "Przypnij do tego repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:218
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:229
+msgid "web_ui.items.single_version.pin_ver_button"
+msgstr "Przypnij do wersji"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:221
+msgid "web_ui.items.single_version.pinned_ver"
+msgstr "Pakiet jest przypięty do tej wersji."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:224
+msgid "web_ui.items.single_version.repin_ver_button"
+msgstr "Przypnij do tej wersji"
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:226
+msgid "web_ui.items.single_version.pinned_other_ver"
+msgstr "Pakiet jest przypięty do innej wersji."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:234
+msgid "web_ui.items.single_version.active_ver_is_this_one"
+msgstr "Ta wersja jest obecnie aktywnÄ… wersjÄ…."
+
+#: src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja:238
+msgid "web_ui.items.single_version.active_ver_is_{}"
+msgstr "Obecnie aktywna wersja to '{}'."
+
+#: src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja:23
+msgid "web_ui.packages.title"
+msgstr "Pakiety"
+
+#: src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja:40
+msgid "web_ui.packages.heading"
+msgstr "Dostępne pakiety"
+
+#: src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja:76
+msgid "web_ui.packages.enabled_version_{}"
+msgstr "aktywowano wersjÄ™ {}"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:23
+msgid "web_ui.landing.title"
+msgstr "Strona lÄ…dowania"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:27
+msgid "web_ui.landing.heading.haketilo_is_running"
+msgstr "Haketilo działa"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:31
+msgid "web_ui.landing.web_ui.landing.what_to_do_1"
+msgstr ""
+"Aby móc przeglądać strony przez Haketilo, upewnij się, że Twoja "
+"przeglądarka jest skonfigurowana, aby używać go jako proxy zarówno dla "
+"połączeń HTTP, jak i HTTPs. Użyj następujących wartości."
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:34
+msgid "web_ui.landing.host_label"
+msgstr "Adres"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:40
+msgid "web_ui.landing.port_label"
+msgstr "Port"
+
+#: src/hydrilla/proxy/web_ui/templates/landing.html.jinja:47
+msgid "web_ui.landing.html.what_to_do_2"
+msgstr ""
+"Jeśli skonfigurowałeś przeglądarkę poprawnie, możesz odwiedzić <a "
+"href=\"http://hkt.mitm.it\">http://hkt.mitm.it</a>. To strona "
+"konfiguracji Haketilo hostowana lokalnie \"wewnÄ…trz\" proxy."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:24
+msgid "web_ui.prompts.auto_install_error.title"
+msgstr "BÅ‚Ä…d instalacji"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:29
+msgid "web_ui.err.retry_install.file_installation_error"
+msgstr ""
+"Podczas ponownej próby instalacji elementów z repozytorium wystąpił "
+"kolejny błąd."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:33
+msgid "web_ui.err.retry_install.repo_communication_error"
+msgstr "Podczas ponownej próby porozumienia z repozytorium wystąpił kolejny błąd."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:37
+msgid "web_ui.prompts.auto_install_error.heading"
+msgstr "BÅ‚Ä…d instalacji"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:42
+msgid "web_ui.prompts.auto_install_error.package_{}_failed_to_install"
+msgstr ""
+"Nie udało się zainstalować automatycznie aktywowanego pakietu '{}', "
+"ponieważ Haketilo nie było w stanie pobrać plików pakietu z serwera "
+"repozytorium. Sprawdź, czy komputer jest podłączony do sieci i spróbuj "
+"ponownie. Możesz również trwale dezaktywować pakiet."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:47
+msgid "web_ui.prompts.auto_install_error.disable_button"
+msgstr "Dezaktywuj"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja:48
+msgid "web_ui.prompts.auto_install_error.retry_button"
+msgstr "Spróbuj ponownie"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:25
+msgid "web_ui.prompts.package_suggestion.title"
+msgstr "Proponowany pakiet"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:38
+msgid "web_ui.prompts.package_suggestion.heading"
+msgstr "Znaleziono pakiet pasujÄ…cy do tej strony"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:43
+msgid "web_ui.prompts.package_suggestion.do_you_want_to_enable_package_{}"
+msgstr ""
+"Czy chcesz aktywować pakiet '{}'? Jeśli to zrobisz, będzie on używany "
+"przy każdej następnej wizycie na stronie."
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:48
+msgid "web_ui.prompts.package_suggestion.disable_button"
+msgstr "Dezaktywuj"
+
+#: src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja:49
+msgid "web_ui.prompts.package_suggestion.enable_button"
+msgstr "Aktywuj"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:23
+msgid "web_ui.repos.add.title"
+msgstr "Nowe repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:27
+msgid "web_ui.repos.add.heading"
+msgstr "Skonfiguruj nowe repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:32
+msgid "web_ui.repos.add.name_field_label"
+msgstr "Nazwa"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:34
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:68
+msgid "web_ui.err.repo_name_invalid"
+msgstr "Wybrana nazwa jest niepoprawna."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:37
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:72
+msgid "web_ui.err.repo_name_taken"
+msgstr "Wybrana nazwa jest już w użyciu."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:41
+msgid "web_ui.repos.add.url_field_label"
+msgstr "URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:43
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:116
+msgid "web_ui.err.repo_url_invalid"
+msgstr "Wybrany URL jest niepoprawny."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja:49
+msgid "web_ui.repos.add.submit_button"
+msgstr "Dodaj repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:23
+msgid "web_ui.repos.title"
+msgstr "Repozytoria"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:33
+msgid "web_ui.repos.heading"
+msgstr "ZarzÄ…dzaj repozytoriami"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:39
+msgid "web_ui.repos.add_repo_button"
+msgstr "Skonfiguruj nowe repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:44
+msgid "web_ui.repos.repo_list_heading"
+msgstr "Zkonfigurowane repozytoria"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:67
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:82
+msgid "web_ui.repos.package_count_{}"
+msgstr "pakiety: {}"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja:79
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:47
+msgid "web_ui.repos.local_packages_semirepo"
+msgstr "Lokalne"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:23
+msgid "web_ui.repos.single.title"
+msgstr "PrzeglÄ…d repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:43
+msgid "web_ui.err.repo_api_version_unsupported"
+msgstr ""
+"repozytorium używa niewspieranej wersji API. Być może musisz "
+"zaktualizować Haketilo."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:50
+msgid "web_ui.repos.single.heading.name_{}"
+msgstr "Repozytorium '{}'"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:53
+msgid "web_ui.repos.single.name_label"
+msgstr "Nazwa"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:59
+msgid "web_ui.repos.single.update_name_button"
+msgstr "Zmień nazwę"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:82
+msgid "web_ui.repos.single.no_update_name_button"
+msgstr "Anuluj"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:86
+msgid "web_ui.repos.single.commit_update_name_button"
+msgstr "Ustaw nowa nazwÄ™"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:97
+msgid "web_ui.repos.single.repo_is_deleted"
+msgstr ""
+"to repozytorium zostało usunięte ale wciąż obecne są pochodzące z niego "
+"pakiety."
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:102
+msgid "web_ui.repos.single.url_label"
+msgstr "URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:108
+msgid "web_ui.repos.single.update_url_button"
+msgstr "Zmień URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:124
+msgid "web_ui.repos.single.no_update_url_button"
+msgstr "Anuluj"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:128
+msgid "web_ui.repos.single.commit_update_url_button"
+msgstr "Ustaw nowy URL"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:135
+msgid "web_ui.repos.single.last_refreshed_label"
+msgstr "Ostanie odświeżenie"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:139
+msgid "web_ui.repos.single.repo_never_refreshed"
+msgstr "To repozytorium nie było jeszcze odświeżane"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:148
+msgid "web_ui.repos.single.stats_label"
+msgstr "Statystyki"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:153
+msgid "web_ui.repos.item_count_{mappings}_{resources}"
+msgstr "pakiety: {mappings}; biblioteki: {resources}"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:161
+msgid "web_ui.repos.item_count_{mappings}"
+msgstr "pakiety: {mappings}"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:171
+msgid "web_ui.repos.single.actions_label"
+msgstr "Działania"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:173
+msgid "web_ui.repos.single.remove_button"
+msgstr "Usuń repozytorium"
+
+#: src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja:174
+msgid "web_ui.repos.single.refresh_button"
+msgstr "Odśwież"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:23
+msgid "web_ui.rules.add.title"
+msgstr "Nowa reguła"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:27
+msgid "web_ui.rules.add.heading"
+msgstr "Zdefiniuj nową regułę"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:32
+msgid "web_ui.rules.add.pattern_field_label"
+msgstr "Wzorzec URL"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:35
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:56
+msgid "web_ui.err.rule_pattern_invalid"
+msgstr "Wybrany wzorzec URL jest niepoprawny."
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:40
+msgid "web_ui.rules.add.block_or_allow_label"
+msgstr "Traktowanie JavaScript'u strony"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:44
+msgid "web_ui.rules.add.block_label"
+msgstr "blokuj"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:49
+msgid "web_ui.rules.add.allow_label"
+msgstr "zezwalaj"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja:56
+msgid "web_ui.rules.add.submit_button"
+msgstr "Dodaj regułę"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:23
+msgid "web_ui.rules.title"
+msgstr "Blokowanie skryptów"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:33
+msgid "web_ui.rules.heading"
+msgstr "Zarządzaj blokowaniem skryptów"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:39
+msgid "web_ui.rules.add_rule_button"
+msgstr "Zdefiniuj nową regułę"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja:44
+msgid "web_ui.rules.rule_list_heading"
+msgstr "Zdefiniowane reguły"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:23
+msgid "web_ui.rules.single.title"
+msgstr "Przegląd reguły"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:36
+msgid "web_ui.rules.single.heading.allow"
+msgstr "Reguła zezwalająca"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:38
+msgid "web_ui.rules.single.heading.block"
+msgstr "Reguła blokująca"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:42
+msgid "web_ui.rules.single.pattern_label"
+msgstr "Wzorzec URL"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:48
+msgid "web_ui.rules.single.update_pattern_button"
+msgstr "Zmień wzorzec URL"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:66
+msgid "web_ui.rules.single.no_update_pattern_button"
+msgstr "Anuluj"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:70
+msgid "web_ui.rules.single.commit_update_pattern_button"
+msgstr "Ustaw nowy wzorzec"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:77
+msgid "web_ui.rules.single.block_or_allow_label"
+msgstr "Funkcja reguły"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:82
+msgid "web_ui.rules.single.allow_button"
+msgstr "Zezwól na JavaScript"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:83
+msgid "web_ui.rules.single.block_button"
+msgstr "Blokuj JavaScript"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:101
+msgid "web_ui.rules.single.actions_label"
+msgstr "Działania"
+
+#: src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja:103
+msgid "web_ui.rules.single.remove_button"
+msgstr "Usuń regułę"
+
+#: src/hydrilla/proxy/web_ui/templates/web_ui_base.html.jinja:20
+msgid "web_ui.base.title.haketilo_proxy"
+msgstr "Haketilo"
+
+#: src/hydrilla/server/malcontent.py:77
+msgid "err.server.malcontent_path_not_dir_{}"
+msgstr "Podana ścieżka 'malcontent_dir' nie wskazuje na katalog: {}"
+
+#: src/hydrilla/server/malcontent.py:96
+msgid "err.server.couldnt_load_item_from_{}"
+msgstr "Nie udało się załadować elementu z {}."
+
+#: src/hydrilla/server/malcontent.py:109
+msgid "err.server.no_file_{required_by}_{ver}_{file}_{sha256}"
+msgstr ""
+"'{required_by}', wersja '{ver}' używa pliku {file} z wartością SHA256 "
+"równą {sha256} ale plik nie istnieje."
+
+#: src/hydrilla/server/malcontent.py:133
+msgid "err.server.item_{item}_in_file_{file}"
+msgstr "Element {item} niespodzeiwanie obecny w pliku {file}."
+
+#: src/hydrilla/server/malcontent.py:139
+msgid "item_version_{ver}_in_file_{file}"
+msgstr "Wersja {ver} elementu niespodziewanie obecna pod {file}."
+
+#: src/hydrilla/server/malcontent.py:166
+msgid "err.server.no_dep_{resource}_{ver}_{dep}"
+msgstr "Nieznana zależność '{dep}' zasobu '{resource}', wersji '{ver}'."
+
+#: src/hydrilla/server/malcontent.py:181
+msgid "err.server.no_payload_{mapping}_{ver}_{payload}"
+msgstr "Nieznany modyfikator '{payload}' odwzorowania '{mapping}', wersji '{ver}'."
+
+#: src/hydrilla/server/malcontent.py:196
+msgid "err.server.no_mapping_{required_by}_{ver}_{required}"
+msgstr ""
+"Nieznane odwzorowanie '{required}' wymagane przez '{required_by}', wersjÄ™"
+" '{ver}'."
+
+#: src/hydrilla/server/malcontent.py:224
+msgid "server.err.couldnt_register_{mapping}_{ver}_{pattern}"
+msgstr ""
+"Nie udało się zarejestrować odwzorowania '{mapping}', wersji '{ver}' "
+"(wzorzec '{pattern}')."
+
+#: src/hydrilla/server/serve.py:81
+msgid "err.server.opt_hydrilla_parent_not_implemented"
+msgstr ""
+"Hydrilla ma się połączyć z nadrzędnym serwerem Hydrilli ale ta "
+"funkcjonalność jeszcze nie została zaimplementowana."
+
+#: src/hydrilla/server/serve.py:217
+msgid "serve_hydrilla_packages_explain_wsgi_considerations"
+msgstr ""
+"Udostępniaj pakiety Hydrilli.\n"
+"\n"
+"Ta komenda ma służyć jako szybki sposób na uruchomienie lokalnej lub "
+"deweloperskiej instancji Hydrilli. Dla lepszej wydajności rozważ użycie "
+"WSGI."
+
+#: src/hydrilla/server/serve.py:220
+msgid "directory_to_serve_from_overrides_config"
+msgstr ""
+"Katalog, z którego mają być serwowane pliki. Powoduje zignorowanie "
+"ewnetualnej wartości z pliku konfiguracyjnego."
+
+#: src/hydrilla/server/serve.py:222
+msgid "project_url_to_display_overrides_config"
+msgstr ""
+"Adres URL projektu do wyświetlania na wygenerowanych stronach HTML. "
+"Powoduje zignorowanie ewnetualnej wartości z pliku konfiguracyjnego."
+
+#: src/hydrilla/server/serve.py:224
+msgid "tcp_port_to_listen_on_overrides_config"
+msgstr ""
+"Numer portu TCP do nasłuchiwania (0-65535). Powoduje zignorowanie "
+"ewnetualnej wartości z pliku konfiguracyjnego."
+
+#: src/hydrilla/server/serve.py:227
+msgid "path_to_config_file_explain_default"
+msgstr ""
+"Ścieżka do pliku konfiguracyjnego Hydrilli (opcjonalna, domyślnie "
+"Hydrilla ładuje swój własny plik konfiguracyjny, który z kolei próbuje "
+"załadować `/etc/hydrilla/config.json`)."
+
+#: src/hydrilla/server/serve.py:259
+msgid "config_option_{}_not_supplied"
+msgstr "BrakujÄ…ca opcja konfiguracji '{}'."
+
+#: src/hydrilla/server/serve.py:263
+msgid "serve_hydrilla_packages_wsgi_help"
+msgstr ""
+"Udostępniaj pakiety Hydrilli.\n"
+"\n"
+"Niniejszy program to skrypt WSGI, który uruchamia repozytorium Hydrilli "
+"za serwerem HTTP takim jak Apache2 czy Nginx. Możesz skonfigurować "
+"HydrillÄ™ przez plik `/etc/hydrilla/config.json`."
+
+#: src/hydrilla/url_patterns.py:127
+msgid "err.url_pattern_{}.bad"
+msgstr "Niepoprawny wzorzec URL: {}"
+
+#: src/hydrilla/url_patterns.py:130
+msgid "err.url_{}.bad"
+msgstr "Niepoprawny URL: {}"
+
+#: src/hydrilla/url_patterns.py:137
+msgid "err.url_pattern_{}.bad_scheme"
+msgstr "Wzorzec URL nieznanego typu: {}"
+
+#: src/hydrilla/url_patterns.py:140
+msgid "err.url_{}.bad_scheme"
+msgstr "URL nieznanego typu: {}"
+
+#: src/hydrilla/url_patterns.py:145
+msgid "err.url_pattern_{}.special_scheme_port"
+msgstr "Wzorzec URL precyzuje port, chociaż nie powinien: {}"
+
+#: src/hydrilla/url_patterns.py:157
+msgid "err.url_pattern_{}.bad_port"
+msgstr "Wzorzec URL precyzuje port spoza dozwolonego zakresu (1-65535): {}"
+
+#: src/hydrilla/url_patterns.py:160
+msgid "err.url_{}.bad_port"
+msgstr "URL precyzuje port spoza dozwolonego zakresu (1-65535): {}"
+
+#: src/hydrilla/url_patterns.py:181
+msgid "err.url_pattern_{}.has_query"
+msgstr ""
+"Wzorzec URL zawiera kwerendę wprowadzoną przez pytajnik, choć nie "
+"powinien: {}"
+
+#: src/hydrilla/url_patterns.py:185
+msgid "err.url_pattern_{}.has_frag"
+msgstr ""
+"Wzorzec URL zawiera urywek wprowadzony przez znak hasz (`#`), choć nie "
+"powinien: {}"
+
diff --git a/src/hydrilla/mitmproxy_launcher/__init__.py b/src/hydrilla/mitmproxy_launcher/__init__.py
new file mode 100644
index 0000000..d382ead
--- /dev/null
+++ b/src/hydrilla/mitmproxy_launcher/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
diff --git a/src/hydrilla/mitmproxy_launcher/__main__.py b/src/hydrilla/mitmproxy_launcher/__main__.py
new file mode 100644
index 0000000..f2ec78a
--- /dev/null
+++ b/src/hydrilla/mitmproxy_launcher/__main__.py
@@ -0,0 +1,11 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+import sys
+
+from . import launch
+
+launch.launch()
diff --git a/src/hydrilla/mitmproxy_launcher/addon_script.py.mitmproxy b/src/hydrilla/mitmproxy_launcher/addon_script.py.mitmproxy
new file mode 100644
index 0000000..fe853d1
--- /dev/null
+++ b/src/hydrilla/mitmproxy_launcher/addon_script.py.mitmproxy
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+from hydrilla.proxy.addon import HaketiloAddon
+
+addons = [HaketiloAddon()]
diff --git a/src/hydrilla/mitmproxy_launcher/launch.py b/src/hydrilla/mitmproxy_launcher/launch.py
new file mode 100644
index 0000000..3b7749d
--- /dev/null
+++ b/src/hydrilla/mitmproxy_launcher/launch.py
@@ -0,0 +1,104 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Code for starting mitmproxy
+#
+# This file is part of Hydrilla
+#
+# Copyright (C) 2021, 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.
+
+
+import sys
+import os
+import subprocess as sp
+import typing as t
+
+from pathlib import Path
+from shutil import copytree
+# The following import requires at least Python 3.8. There is no point adding
+# a workaround for Python 3.7 because mitmproxy itself (which we're loading
+# here) relies on Python 3.9+. This does not affect the Hydrilla server and
+# builder which continue to work under Python 3.7.
+from importlib.metadata import distribution
+
+import click
+
+from .. import _version
+from ..translations import smart_gettext as _
+
+
+here = Path(__file__).resolve().parent
+
+
+xdg_state_home = os.environ.get('XDG_STATE_HOME', '.local/state')
+default_dir = str(Path.home() / xdg_state_home / 'haketilo')
+old_default_dir_path = Path.home() / '.haketilo/'
+
+@click.command(help=_('cli_help.haketilo'))
+@click.option('-l', '--listen-host', default='127.0.0.1', type=click.STRING,
+ help=_('cli_opt.haketilo.listen_host'))
+@click.option('-p', '--port', default=8080, type=click.IntRange(1, 65535),
+ help=_('cli_opt.haketilo.port'))
+@click.option('-L/-l', '--launch-browser/--no-launch-browser', default=True,
+ help=_('cli_opt.haketilo.launch_browser'))
+@click.option('-d', '--directory', default=default_dir,
+ type=click.Path(file_okay=False),
+ help=_('cli_opt.haketilo.dir_defaults_to_{}').format(default_dir))
+@click.version_option(version=_version.version, prog_name='Haketilo proxy',
+ message=_('%(prog)s_%(version)s_license'),
+ help=_('cli_opt.haketilo.version'))
+def launch(listen_host: str, port: int, launch_browser: bool, directory: str) \
+ -> t.NoReturn:
+ directory_path = Path(os.path.expanduser(directory)).resolve()
+
+ # Before we started using XDG_STATE_HOME, we were storing files by default
+ # under ~/.haketilo. Let's make sync state from there to our new default
+ # state directory
+ if directory == default_dir and \
+ old_default_dir_path.exists() and \
+ not directory_path.exists():
+ directory_path.parent.mkdir(parents=True, exist_ok=True)
+ copytree(old_default_dir_path, directory_path, symlinks=True)
+
+ directory_path.mkdir(parents=True, exist_ok=True)
+
+ launch_browser_str = 'true' if launch_browser else 'false'
+
+ sys.argv = [
+ 'mitmdump',
+ '--listen-host', listen_host,
+ '-p', str(port),
+ '--set', f'confdir={directory_path / "mitmproxy"}',
+ '--set', 'upstream_cert=false',
+ '--set', 'connection_strategy=lazy',
+ '--set', f'haketilo_dir={directory_path}',
+ '--set', f'haketilo_listen_host={listen_host}',
+ '--set', f'haketilo_listen_port={port}',
+ '--set', f'haketilo_launch_browser={launch_browser_str}',
+ '--scripts', str(here / 'addon_script.py.mitmproxy')
+ ]
+
+ for entry_point in distribution('mitmproxy').entry_points:
+ if entry_point.group == 'console_scripts' and \
+ entry_point.name == 'mitmdump':
+ sys.exit(entry_point.load()())
+
+ sys.exit(1)
diff --git a/src/hydrilla/pattern_tree.py b/src/hydrilla/pattern_tree.py
new file mode 100644
index 0000000..5671b2b
--- /dev/null
+++ b/src/hydrilla/pattern_tree.py
@@ -0,0 +1,311 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Data structure for querying URL patterns.
+#
+# This file is part of Hydrilla&Haketilo.
+#
+# Copyright (C) 2021, 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 defines data structures for querying data using URL patterns.
+"""
+
+import typing as t
+import dataclasses as dc
+
+from immutables import Map
+
+from .url_patterns import ParsedPattern, ParsedUrl, parse_url#, catchall_pattern
+from .translations import smart_gettext as _
+
+
+WrapperStoredType = t.TypeVar('WrapperStoredType', bound=t.Hashable)
+
+@dc.dataclass(frozen=True, unsafe_hash=True, order=True)
+class StoredTreeItem(t.Generic[WrapperStoredType]):
+ """
+ In the Pattern Tree, each item is stored together with the pattern used to
+ register it.
+ """
+ item: WrapperStoredType
+ pattern: ParsedPattern
+
+
+NodeStoredType = t.TypeVar('NodeStoredType')
+
+@dc.dataclass(frozen=True)
+class PatternTreeNode(t.Generic[NodeStoredType]):
+ """...."""
+ SelfType = t.TypeVar('SelfType', bound='PatternTreeNode[NodeStoredType]')
+
+ ChildrenType = Map[str, SelfType]
+
+ children: 'ChildrenType' = Map()
+ literal_match: t.Optional[NodeStoredType] = None
+
+ def is_empty(self) -> bool:
+ """...."""
+ return len(self.children) == 0 and self.literal_match is None
+
+ def update_literal_match(
+ self: 'SelfType',
+ new_match_item: t.Optional[NodeStoredType]
+ ) -> 'SelfType':
+ """...."""
+ return dc.replace(self, literal_match=new_match_item)
+
+ def get_child(self: 'SelfType', child_key: str) -> t.Optional['SelfType']:
+ """...."""
+ return self.children.get(child_key)
+
+ def remove_child(self: 'SelfType', child_key: str) -> 'SelfType':
+ """...."""
+ try:
+ children = self.children.delete(child_key)
+ except:
+ children = self.children
+
+ return dc.replace(self, children=children)
+
+ def set_child(self: 'SelfType', child_key: str, child: 'SelfType') \
+ -> 'SelfType':
+ """...."""
+ return dc.replace(self, children=self.children.set(child_key, child))
+
+
+BranchStoredType = t.TypeVar('BranchStoredType')
+
+BranchItemUpdater = t.Callable[
+ [t.Optional[BranchStoredType]],
+ t.Optional[BranchStoredType]
+]
+
+@dc.dataclass(frozen=True)
+class PatternTreeBranch(t.Generic[BranchStoredType]):
+ """...."""
+ SelfType = t.TypeVar(
+ 'SelfType',
+ bound = 'PatternTreeBranch[BranchStoredType]'
+ )
+
+ root_node: PatternTreeNode[BranchStoredType] = PatternTreeNode()
+
+ def is_empty(self) -> bool:
+ """...."""
+ return self.root_node.is_empty()
+
+ def update(
+ self: 'SelfType',
+ segments: t.Iterable[str],
+ item_updater: BranchItemUpdater
+ ) -> 'SelfType':
+ """
+ .......
+ """
+ node = self.root_node
+ nodes_segments = []
+
+ for segment in segments:
+ next_node = node.get_child(segment)
+
+ nodes_segments.append((node, segment))
+
+ node = PatternTreeNode() if next_node is None else next_node
+
+ node = node.update_literal_match(item_updater(node.literal_match))
+
+ while nodes_segments:
+ prev_node, segment = nodes_segments.pop()
+
+ if node.is_empty():
+ node = prev_node.remove_child(segment)
+ else:
+ node = prev_node.set_child(segment, node)
+
+ return dc.replace(self, root_node=node)
+
+ def search(self, segments: t.Sequence[str]) -> t.Iterable[BranchStoredType]:
+ """
+ Yields all matches of this segments sequence against the tree. Results
+ are produced in order from greatest to lowest pattern specificity.
+ """
+ nodes = [self.root_node]
+
+ for segment in segments:
+ next_node = nodes[-1].get_child(segment)
+ if next_node is None:
+ break
+
+ nodes.append(next_node)
+
+ nsegments = len(segments)
+ cond_literal = lambda: len(nodes) == nsegments
+ cond_wildcard = [
+ lambda: len(nodes) + 1 == nsegments and segments[-1] != '*',
+ lambda: len(nodes) + 1 < nsegments,
+ lambda: len(nodes) + 1 != nsegments or segments[-1] != '***'
+ ]
+
+ while nodes:
+ node = nodes.pop()
+
+ wildcard_matches = [node.get_child(wc) for wc in ('*', '**', '***')]
+
+ for match_node, condition in [
+ (node, cond_literal),
+ *zip(wildcard_matches, cond_wildcard)
+ ]:
+ if match_node is not None:
+ if match_node.literal_match is not None:
+ if condition():
+ yield match_node.literal_match
+
+
+FilterStoredType = t.TypeVar('FilterStoredType', bound=t.Hashable)
+FilterWrappedType = StoredTreeItem[FilterStoredType]
+
+def filter_by_trailing_slash(
+ items: t.Iterable[FilterWrappedType],
+ with_slash: bool
+) -> t.FrozenSet[FilterWrappedType]:
+ """...."""
+ return frozenset(wrapped for wrapped in items
+ if with_slash == wrapped.pattern.has_trailing_slash)
+
+TreeStoredType = t.TypeVar('TreeStoredType', bound=t.Hashable)
+
+StoredSet = t.FrozenSet[StoredTreeItem[TreeStoredType]]
+PathBranch = PatternTreeBranch[StoredSet]
+DomainBranch = PatternTreeBranch[PathBranch]
+TreeRoot = Map[t.Tuple[str, t.Optional[int]], DomainBranch]
+
+@dc.dataclass(frozen=True)
+class PatternTree(t.Generic[TreeStoredType]):
+ """
+ "Pattern Tree" is how we refer to the data structure used for querying
+ Haketilo patterns. Those look like 'https://*.example.com/ab/***'. The goal
+ is to make it possible to quickly retrieve all known patterns that match
+ a given URL.
+ """
+ SelfType = t.TypeVar('SelfType', bound='PatternTree[TreeStoredType]')
+
+ _by_scheme_and_port: TreeRoot = Map()
+
+ def _register(
+ self: 'SelfType',
+ parsed_pattern: ParsedPattern,
+ item: TreeStoredType,
+ register: bool = True
+ ) -> 'SelfType':
+ """
+ Make an item wrapped in StoredTreeItem object queryable through the
+ Pattern Tree by the given parsed URL pattern.
+ """
+ wrapped_item = StoredTreeItem(item, parsed_pattern)
+
+ def item_updater(item_set: t.Optional[StoredSet]) \
+ -> t.Optional[StoredSet]:
+ """...."""
+ if item_set is None:
+ item_set = frozenset()
+
+ if register:
+ item_set = item_set.union((wrapped_item,))
+ else:
+ item_set = item_set.difference((wrapped_item,))
+
+ return None if len(item_set) == 0 else item_set
+
+ def path_branch_updater(path_branch: t.Optional[PathBranch]) \
+ -> t.Optional[PathBranch]:
+ """...."""
+ if path_branch is None:
+ path_branch = PatternTreeBranch()
+
+ path_branch = path_branch.update(
+ parsed_pattern.path_segments,
+ item_updater
+ )
+
+ return None if path_branch.is_empty() else path_branch
+
+ key = (parsed_pattern.scheme, parsed_pattern.port)
+ domain_tree = self._by_scheme_and_port.get(key, PatternTreeBranch())
+
+ new_domain_tree = domain_tree.update(
+ parsed_pattern.domain_labels,
+ path_branch_updater
+ )
+
+ if new_domain_tree.is_empty():
+ try:
+ new_root = self._by_scheme_and_port.delete(key)
+ except KeyError:
+ new_root = self._by_scheme_and_port
+ else:
+ new_root = self._by_scheme_and_port.set(key, new_domain_tree)
+
+ return dc.replace(self, _by_scheme_and_port=new_root)
+
+ def register(
+ self: 'SelfType',
+ parsed_pattern: ParsedPattern,
+ item: TreeStoredType
+ ) -> 'SelfType':
+ """
+ Make item queryable through the Pattern Tree by the given URL pattern.
+ """
+ return self._register(parsed_pattern, item)
+
+ def deregister(
+ self: 'SelfType',
+ parsed_pattern: ParsedPattern,
+ item: TreeStoredType
+ ) -> 'SelfType':
+ """
+ Make item no longer queryable through the Pattern Tree by the given URL
+ pattern.
+ """
+ return self._register(parsed_pattern, item, register=False)
+
+ def search(self, url: t.Union[ParsedUrl, str]) -> t.Iterable[StoredSet]:
+ """
+ ....
+ """
+ parsed_url = parse_url(url) if isinstance(url, str) else url
+
+ key = (parsed_url.scheme, parsed_url.port)
+ domain_tree = self._by_scheme_and_port.get(key)
+ if domain_tree is None:
+ return
+
+ if parsed_url.has_trailing_slash:
+ slash_options = [True, False]
+ else:
+ slash_options = [False]
+
+ for path_tree in domain_tree.search(parsed_url.domain_labels):
+ for item_set in path_tree.search(parsed_url.path_segments):
+ for with_slash in slash_options:
+ items = filter_by_trailing_slash(item_set, with_slash)
+ if len(items) > 0:
+ yield items
diff --git a/src/hydrilla/proxy/__init__.py b/src/hydrilla/proxy/__init__.py
new file mode 100644
index 0000000..d382ead
--- /dev/null
+++ b/src/hydrilla/proxy/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
diff --git a/src/hydrilla/proxy/addon.py b/src/hydrilla/proxy/addon.py
new file mode 100644
index 0000000..98894e7
--- /dev/null
+++ b/src/hydrilla/proxy/addon.py
@@ -0,0 +1,379 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo addon for Mitmproxy.
+#
+# 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 contains the definition of a mitmproxy addon that gets instantiated
+from addon script.
+"""
+
+import sys
+import re
+import threading
+import secrets
+import typing as t
+import dataclasses as dc
+import traceback as tb
+
+from pathlib import Path
+from contextlib import contextmanager
+from urllib.parse import urlparse
+
+from mitmproxy import tls, http, addonmanager, ctx
+from mitmproxy.script import concurrent
+
+from ..exceptions import HaketiloException
+from ..translations import smart_gettext as _
+from .. import url_patterns
+from .state_impl import ConcreteHaketiloState
+from . import state
+from . import policies
+from . import http_messages
+
+
+class LoggerToMitmproxy(state.Logger):
+ def warn(self, msg: str) -> None:
+ ctx.log.warn(f'Haketilo: {msg}')
+
+
+def safe_parse_url(url: str) -> url_patterns.ParsedUrl:
+ try:
+ return url_patterns.parse_url(url)
+ except url_patterns.HaketiloURLException:
+ return url_patterns.dummy_url
+
+
+@dc.dataclass
+class FlowHandling:
+ flow: http.HTTPFlow
+ policy: policies.Policy
+ _bl_request_info: http_messages.BodylessRequestInfo
+ _request_info: t.Optional[http_messages.RequestInfo] = None
+ _bl_response_info: t.Optional[http_messages.BodylessResponseInfo] = None
+
+ @property
+ def bl_request_info(self) -> http_messages.BodylessRequestInfo:
+ return self._bl_request_info
+
+ @property
+ def request_info(self) -> http_messages.RequestInfo:
+ if self._request_info is None:
+ body = self.flow.request.get_content(strict=False) or b''
+ self._request_info = self._bl_request_info.with_body(body)
+
+ return self._request_info
+
+ @property
+ def bl_response_info(self) -> http_messages.BodylessResponseInfo:
+ if self._bl_response_info is None:
+ assert self.flow.response is not None
+
+ self._bl_response_info = http_messages.BodylessResponseInfo.make(
+ url = safe_parse_url(self.flow.request.url),
+ status_code = self.flow.response.status_code,
+ headers = self.flow.response.headers
+ )
+
+ return self._bl_response_info
+
+ @property
+ def response_info(self) -> http_messages.ResponseInfo:
+ assert self.flow.response is not None
+
+ body = self.flow.response.get_content(strict=False) or b''
+ return self.bl_response_info.with_body(body)
+
+ @property
+ def full_http_info(self) -> http_messages.FullHTTPInfo:
+ return http_messages.FullHTTPInfo(self.request_info, self.response_info)
+
+ @staticmethod
+ def make(
+ flow: http.HTTPFlow,
+ policy: policies.Policy,
+ url: url_patterns.ParsedUrl
+ ) -> 'FlowHandling':
+ bl_request_info = http_messages.BodylessRequestInfo.make(
+ url = url,
+ method = flow.request.method,
+ headers = flow.request.headers
+ )
+
+ return FlowHandling(flow, policy, bl_request_info)
+
+
+@dc.dataclass
+class PassedOptions:
+ haketilo_dir: t.Optional[str] = None
+ haketilo_listen_host: t.Optional[str] = None
+ haketilo_listen_port: t.Optional[int] = None
+ haketilo_launch_browser: t.Optional[bool] = None
+
+ @property
+ def fully_configured(self) -> bool:
+ return (self.haketilo_dir is not None and
+ self.haketilo_listen_host is not None and
+ self.haketilo_listen_port is not None and
+ self.haketilo_launch_browser is not None)
+
+
+Lock = threading.Lock
+
+@dc.dataclass
+class HaketiloAddon:
+ initial_options: PassedOptions = PassedOptions()
+ configured: bool = False
+ configured_lock: Lock = dc.field(default_factory=Lock)
+
+ handling_dict: dict[int, FlowHandling] = dc.field(default_factory=dict)
+ handling_dict_lock: Lock = dc.field(default_factory=Lock)
+
+ logger: LoggerToMitmproxy = dc.field(default_factory=LoggerToMitmproxy)
+
+ state: t.Optional[ConcreteHaketiloState] = None
+
+ def load(self, loader: addonmanager.Loader) -> None:
+ """...."""
+ loader.add_option(
+ name = 'haketilo_dir',
+ typespec = str,
+ default = '~/.haketilo/',
+ help = "Point to a Haketilo data directory to use"
+ )
+ loader.add_option(
+ name = 'haketilo_listen_host',
+ typespec = str,
+ default = '127.0.0.1',
+ help = "Specify the address proxy listens on"
+ )
+ loader.add_option(
+ name = 'haketilo_listen_port',
+ typespec = int,
+ default = 8080,
+ help = "Specify the port listens on"
+ )
+ loader.add_option(
+ name = 'haketilo_launch_browser',
+ typespec = bool,
+ default = True,
+ help = "Specify whether to attempt to open a browser window with Haketilo page displayed inside"
+ )
+
+ def configure(self, updated: set[str]) -> None:
+ with self.configured_lock:
+ val_names = ('dir', 'listen_host', 'listen_port', 'launch_browser')
+ for val_name in val_names:
+ key = f'haketilo_{val_name}'
+
+ if key not in updated:
+ continue
+
+ if getattr(self.initial_options, key) is not None:
+ fmt = _('warn.proxy.setting_already_configured_{}')
+ self.logger.warn(fmt.format(key))
+ continue
+
+ new_val = getattr(ctx.options, key)
+ setattr(self.initial_options, key, new_val)
+
+ if self.configured or not self.initial_options.fully_configured:
+ return
+
+ try:
+ haketilo_dir = self.initial_options.haketilo_dir
+ listen_host = self.initial_options.haketilo_listen_host
+ listen_port = self.initial_options.haketilo_listen_port
+
+ self.state = ConcreteHaketiloState.make(
+ store_dir = Path(t.cast(str, haketilo_dir)) / 'store',
+ listen_host = t.cast(str, listen_host),
+ listen_port = t.cast(int, listen_port),
+ logger = self.logger
+ )
+ except Exception as e:
+ tb.print_exception(None, e, e.__traceback__)
+ sys.exit(1)
+
+ self.configured = True
+
+ def running(self) -> None:
+ with self.configured_lock:
+ assert self.configured
+
+ assert self.state is not None
+
+ if self.initial_options.haketilo_launch_browser:
+ if not self.state.launch_browser():
+ self.logger.warn(_('warn.proxy.couldnt_launch_browser'))
+
+ def get_flow_handling(self, flow: http.HTTPFlow) -> FlowHandling:
+ policy: policies.Policy
+
+ assert self.state is not None
+
+ with self.handling_dict_lock:
+ handling = self.handling_dict.get(id(flow))
+
+ if handling is None:
+ try:
+ parsed_url = url_patterns.parse_url(flow.request.url)
+ except url_patterns.HaketiloURLException as e:
+ haketilo_settings = self.state.get_settings()
+ policy = policies.ErrorBlockPolicy(haketilo_settings, error=e)
+ parsed_url = url_patterns.dummy_url
+ else:
+ policy = self.state.select_policy(parsed_url)
+
+ handling = FlowHandling.make(flow, policy, parsed_url)
+
+ with self.handling_dict_lock:
+ self.handling_dict[id(flow)] = handling
+
+ return handling
+
+ def forget_flow_handling(self, flow: http.HTTPFlow) -> None:
+ with self.handling_dict_lock:
+ self.handling_dict.pop(id(flow), None)
+
+ @contextmanager
+ def http_safe_event_handling(self, flow: http.HTTPFlow) -> t.Iterator:
+ """...."""
+ with self.configured_lock:
+ assert self.configured
+
+ try:
+ yield
+ except Exception as e:
+ tb_string = ''.join(tb.format_exception(None, e, e.__traceback__))
+ error_text = _('err.proxy.unknown_error_{}_try_again')\
+ .format(tb_string)\
+ .encode()
+ flow.response = http.Response.make(
+ status_code = 500,
+ content = error_text,
+ headers = [(b'Content-Type', b'text/plain; charset=utf-8')]
+ )
+
+ self.forget_flow_handling(flow)
+
+ @concurrent
+ def requestheaders(self, flow: http.HTTPFlow) -> None:
+ with self.http_safe_event_handling(flow):
+ referrer = flow.request.headers.get('referer')
+ if referrer is not None:
+ if urlparse(referrer).netloc == 'hkt.mitm.it' and \
+ urlparse(flow.request.url).netloc != 'hkt.mitm.it':
+ # Do not reveal to the site that Haketilo meta-site was
+ # visited before.
+ flow.request.headers.pop('referer', None)
+
+ handling = self.get_flow_handling(flow)
+ policy = handling.policy
+
+ if not policy.should_process_request(handling.bl_request_info):
+ flow.request.stream = True
+ if policy.anticache:
+ flow.request.anticache()
+
+ @concurrent
+ def request(self, flow: http.HTTPFlow) -> None:
+ if flow.request.stream:
+ return
+
+ with self.http_safe_event_handling(flow):
+ handling = self.get_flow_handling(flow)
+
+ result = handling.policy.consume_request(handling.request_info)
+
+ if result is not None:
+ mitmproxy_headers = http.Headers(result.headers.items_bin())
+
+ if isinstance(result, http_messages.RequestInfo):
+ flow.request.url = result.url.orig_url
+ flow.request.method = result.method
+ flow.request.headers = mitmproxy_headers
+ flow.request.set_content(result.body or None)
+ else:
+ # isinstance(result, http_messages.ResponseInfo)
+ flow.response = http.Response.make(
+ status_code = result.status_code,
+ headers = mitmproxy_headers,
+ content = result.body
+ )
+
+ def responseheaders(self, flow: http.HTTPFlow) -> None:
+ assert flow.response is not None
+
+ with self.http_safe_event_handling(flow):
+ handling = self.get_flow_handling(flow)
+
+ if not handling.policy.should_process_response(
+ request_info = handling.request_info,
+ response_info = handling.bl_response_info
+ ):
+ flow.response.stream = True
+
+ @concurrent
+ def response(self, flow: http.HTTPFlow) -> None:
+ assert flow.response is not None
+
+ if flow.response.stream:
+ return
+
+ with self.http_safe_event_handling(flow):
+ handling = self.get_flow_handling(flow)
+
+ new_nonce = secrets.token_urlsafe(8)
+ setattr(policies.response_work_data, 'nonce', new_nonce)
+
+ try:
+ http_info = handling.full_http_info
+ result = handling.policy.consume_response(http_info)
+ finally:
+ delattr(policies.response_work_data, 'nonce')
+
+ if result is not None:
+ headers_bin = result.headers.items_bin()
+
+ flow.response.status_code = result.status_code
+ flow.response.headers = http.Headers(headers_bin)
+ flow.response.set_content(result.body)
+
+ self.forget_flow_handling(flow)
+
+ def tls_clienthello(self, data: tls.ClientHelloData):
+ if data.context.server.address is None:
+ return
+
+ host, port = data.context.server.address
+ if (host == 'hkt.mitm.it' or host.endswith('.hkt.mitm.it')) and \
+ port == 443:
+ return
+
+ data.establish_server_tls_first = True
+
+ def error(self, flow: http.HTTPFlow) -> None:
+ self.forget_flow_handling(flow)
diff --git a/src/hydrilla/proxy/csp.py b/src/hydrilla/proxy/csp.py
new file mode 100644
index 0000000..df2f65b
--- /dev/null
+++ b/src/hydrilla/proxy/csp.py
@@ -0,0 +1,196 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Tools for working with Content Security Policy headers.
+#
+# 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.
+
+"""
+.....
+"""
+
+import re
+import typing as t
+import dataclasses as dc
+
+from immutables import Map, MapMutation
+
+from . import http_messages
+
+
+enforce_header_names = (
+ 'content-security-policy',
+ 'x-content-security-policy',
+ 'x-webkit-csp'
+)
+
+header_names = (*enforce_header_names, 'content-security-policy-report-only')
+
+@dc.dataclass
+class ContentSecurityPolicy:
+ directives: Map[str, t.Sequence[str]]
+ header_name: str = 'Content-Security-Policy'
+ disposition: str = 'enforce'
+
+ def remove(self, directives: t.Sequence[str]) -> 'ContentSecurityPolicy':
+ mutation = self.directives.mutate()
+
+ for name in directives:
+ mutation.pop(name, None)
+
+ return dc.replace(self, directives = mutation.finish())
+
+ def extend(self, directives: t.Mapping[str, t.Sequence[str]]) \
+ -> 'ContentSecurityPolicy':
+ mutation = self.directives.mutate()
+
+ for name, extras in directives.items():
+ if name in mutation:
+ mutation[name] = (*mutation[name], *extras)
+
+ return dc.replace(self, directives = mutation.finish())
+
+ def serialize(self) -> tuple[str, str]:
+ """
+ Produces (name, value) pair suitable for use as an HTTP header.
+
+ If a deserialized policy is being reserialized, the resulting value is
+ not guaranteed to be the same as the original one. It shall be merely
+ semantically equivalent.
+ """
+ serialized_directives = []
+ for name, value_seq in self.directives.items():
+ if all(val == "'none'" for val in value_seq):
+ value_seq = ["'none'"]
+ else:
+ value_seq = [val for val in value_seq if val != "'none'"]
+
+ serialized_directives.append(f'{name} {" ".join(value_seq)}')
+
+ return (self.header_name, ';'.join(serialized_directives))
+
+ @staticmethod
+ def deserialize(
+ serialized: str,
+ header_name: str,
+ disposition: str = 'enforce'
+ ) -> 'ContentSecurityPolicy':
+ """
+ Parses the policy as required by W3C Working Draft.
+
+ Extra whitespace information, invalid/empty directives and the order of
+ directives are not preserved, only the semantically-relevant information
+ is.
+ """
+ # For more info, see:
+ # https://www.w3.org/TR/CSP3/#parse-serialized-policy
+ empty_directives: Map[str, t.Sequence[str]] = Map()
+
+ directives = empty_directives.mutate()
+
+ for serialized_directive in serialized.split(';'):
+ if not serialized_directive.isascii():
+ continue
+
+ serialized_directive = serialized_directive.strip()
+ if len(serialized_directive) == 0:
+ continue
+
+ tokens = serialized_directive.split()
+ directive_name = tokens.pop(0).lower()
+ directive_value = tokens
+
+ # Specs mention giving warnings for duplicate directive names but
+ # from our proxy's perspective this is not important right now.
+ if directive_name in directives:
+ continue
+
+ directives[directive_name] = directive_value
+
+ return ContentSecurityPolicy(
+ directives = directives.finish(),
+ header_name = header_name,
+ disposition = disposition
+ )
+
+# def extract(headers: http_messages.IHeaders) \
+# -> tuple[ContentSecurityPolicy, ...]:
+# """...."""
+# csp_policies = []
+
+# for header_name, disposition in header_names_and_dispositions:
+# for serialized_list in headers.get_all(header_name):
+# for serialized in serialized_list.split(','):
+# policy = ContentSecurityPolicy.deserialize(
+# serialized,
+# header_name,
+# disposition
+# )
+
+# if policy.directives != Map():
+# csp_policies.append(policy)
+
+# return tuple(csp_policies)
+
+def modify(
+ headers: http_messages.IHeaders,
+ clear: t.Union[t.Sequence[str], t.Literal['all']] = (),
+ extend: t.Mapping[str, t.Sequence[str]] = Map(),
+ add: t.Mapping[str, t.Sequence[str]] = Map(),
+) -> http_messages.IHeaders:
+ """
+ This function modifies the CSP Headers. The following actions are performed
+ *in order*
+ 1. report-only CSP Headers are removed,
+ 2. directives with names in `clear` are removed,
+ 3. directives that could cause CSP reports to be sent are removed,
+ 4. directives from `add` are added in a separate Content-Security-Policy,
+ header.
+ 5. directives from `extend` are merged into the existing directives,
+ effectively loosening them,
+
+ No measures are yet implemented to prevent fingerprinting when serving HTTP
+ responses with headers modified by this function. Please use wisely, you
+ have been warned.
+ """
+ headers_list = [
+ (key, val)
+ for key, val in headers.items()
+ if key.lower() not in header_names
+ ]
+
+ if clear != 'all':
+ for name in header_names:
+ for serialized_list in headers.get_all(name):
+ for serialized in serialized_list.split(','):
+ policy = ContentSecurityPolicy.deserialize(serialized, name)
+ policy = policy.remove((*clear, 'report-to', 'report-uri'))
+ policy = policy.extend(extend)
+ if policy.directives != Map():
+ headers_list.append(policy.serialize())
+
+ if add != Map():
+ csp_to_add = ContentSecurityPolicy(Map(add)).extend(extend)
+ headers_list.append(csp_to_add.serialize())
+
+ return http_messages.make_headers(headers_list)
diff --git a/src/hydrilla/proxy/http_messages.py b/src/hydrilla/proxy/http_messages.py
new file mode 100644
index 0000000..74f1f02
--- /dev/null
+++ b/src/hydrilla/proxy/http_messages.py
@@ -0,0 +1,244 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Classes/protocols for representing HTTP requests and responses data.
+#
+# 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.
+
+"""
+.....
+"""
+
+import re
+import cgi
+import dataclasses as dc
+import typing as t
+import sys
+
+if sys.version_info >= (3, 8):
+ from typing import Protocol
+else:
+ from typing_extensions import Protocol
+
+import mitmproxy.http
+
+from .. import url_patterns
+
+
+DefaultGetValue = t.TypeVar('DefaultGetValue', str, None)
+
+class _MitmproxyHeadersWrapper():
+ def __init__(self, headers: mitmproxy.http.Headers) -> None:
+ self.headers = headers
+
+ __getitem__ = lambda self, key: self.headers[key]
+ get_all = lambda self, key: self.headers.get_all(key)
+
+ @t.overload
+ def get(self, key: str) -> t.Optional[str]:
+ ...
+ @t.overload
+ def get(self, key: str, default: DefaultGetValue) \
+ -> t.Union[str, DefaultGetValue]:
+ ...
+ def get(self, key, default = None):
+ value = self.headers.get(key)
+
+ if value is None:
+ return default
+ else:
+ return t.cast(str, value)
+
+ def items(self) -> t.Iterable[tuple[str, str]]:
+ return self.headers.items(multi=True)
+
+ def items_bin(self) -> t.Iterable[tuple[bytes, bytes]]:
+ return tuple((key.encode(), val.encode()) for key, val in self.items())
+
+class IHeaders(Protocol):
+ def __getitem__(self, key: str) -> str: ...
+
+ def get_all(self, key: str) -> t.Iterable[str]: ...
+
+ @t.overload
+ def get(self, key: str) -> t.Optional[str]:
+ ...
+ @t.overload
+ def get(self, key: str, default: DefaultGetValue) \
+ -> t.Union[str, DefaultGetValue]:
+ ...
+
+ def items(self) -> t.Iterable[tuple[str, str]]: ...
+
+ def items_bin(self) -> t.Iterable[tuple[bytes, bytes]]: ...
+
+_AnyHeaders = t.Union[
+ t.Iterable[tuple[bytes, bytes]],
+ t.Iterable[tuple[str, str]],
+ mitmproxy.http.Headers,
+ IHeaders
+]
+
+def make_headers(headers: _AnyHeaders) -> IHeaders:
+ if not isinstance(headers, mitmproxy.http.Headers):
+ if isinstance(headers, t.Iterable):
+ headers = tuple(headers)
+ if not headers or isinstance(headers[0][0], str):
+ headers = ((key.encode(), val.encode()) for key, val in headers)
+
+ headers = mitmproxy.http.Headers(headers)
+ else:
+ # isinstance(headers, IHeaders)
+ return headers
+
+ return _MitmproxyHeadersWrapper(headers)
+
+
+_AnyUrl = t.Union[str, url_patterns.ParsedUrl]
+
+def make_parsed_url(url: t.Union[str, url_patterns.ParsedUrl]) \
+ -> url_patterns.ParsedUrl:
+ return url_patterns.parse_url(url) if isinstance(url, str) else url
+
+
+@dc.dataclass(frozen=True)
+class HasHeadersMixin:
+ headers: IHeaders
+
+ def deduce_content_type(self) -> tuple[t.Optional[str], t.Optional[str]]:
+ content_type_header = self.headers.get('content-type')
+ if content_type_header is None:
+ return (None, None)
+
+ mime, options = cgi.parse_header(content_type_header)
+
+ encoding = options.get('charset')
+ if encoding is not None:
+ encoding = encoding.lower()
+
+ return mime, encoding
+
+
+@dc.dataclass(frozen=True)
+class _BaseRequestInfoFields:
+ url: url_patterns.ParsedUrl
+ method: str
+ headers: IHeaders
+
+@dc.dataclass(frozen=True)
+class BodylessRequestInfo(HasHeadersMixin, _BaseRequestInfoFields):
+ def with_body(self, body: bytes) -> 'RequestInfo':
+ return RequestInfo(self.url, self.method, self.headers, body)
+
+ @staticmethod
+ def make(
+ url: t.Union[str, url_patterns.ParsedUrl],
+ method: str,
+ headers: _AnyHeaders
+ ) -> 'BodylessRequestInfo':
+ url = make_parsed_url(url)
+ return BodylessRequestInfo(url, method, make_headers(headers))
+
+@dc.dataclass(frozen=True)
+class RequestInfo(HasHeadersMixin, _BaseRequestInfoFields):
+ body: bytes
+
+ @staticmethod
+ def make(
+ url: _AnyUrl = url_patterns.dummy_url,
+ method: str = 'GET',
+ headers: _AnyHeaders = (),
+ body: bytes = b''
+ ) -> 'RequestInfo':
+ return BodylessRequestInfo.make(url, method, headers).with_body(body)
+
+AnyRequestInfo = t.Union[BodylessRequestInfo, RequestInfo]
+
+
+@dc.dataclass(frozen=True)
+class _BaseResponseInfoFields:
+ url: url_patterns.ParsedUrl
+ status_code: int
+ headers: IHeaders
+
+@dc.dataclass(frozen=True)
+class BodylessResponseInfo(HasHeadersMixin, _BaseResponseInfoFields):
+ def with_body(self, body: bytes) -> 'ResponseInfo':
+ return ResponseInfo(self.url, self.status_code, self.headers, body)
+
+ @staticmethod
+ def make(
+ url: t.Union[str, url_patterns.ParsedUrl],
+ status_code: int,
+ headers: _AnyHeaders
+ ) -> 'BodylessResponseInfo':
+ url = make_parsed_url(url)
+ return BodylessResponseInfo(url, status_code, make_headers(headers))
+
+@dc.dataclass(frozen=True)
+class ResponseInfo(HasHeadersMixin, _BaseResponseInfoFields):
+ body: bytes
+
+ @staticmethod
+ def make(
+ url: _AnyUrl = url_patterns.dummy_url,
+ status_code: int = 404,
+ headers: _AnyHeaders = (),
+ body: bytes = b''
+ ) -> 'ResponseInfo':
+ bl_info = BodylessResponseInfo.make(url, status_code, headers)
+ return bl_info.with_body(body)
+
+AnyResponseInfo = t.Union[BodylessResponseInfo, ResponseInfo]
+
+
+def is_likely_a_page(
+ request_info: AnyRequestInfo,
+ response_info: AnyResponseInfo
+) -> bool:
+ fetch_dest = request_info.headers.get('sec-fetch-dest')
+ if fetch_dest is None:
+ if 'html' in request_info.headers.get('accept', ''):
+ fetch_dest = 'document'
+ else:
+ fetch_dest = 'unknown'
+
+ if fetch_dest not in ('document', 'iframe', 'frame', 'embed', 'object'):
+ return False
+
+ mime, encoding = response_info.deduce_content_type()
+
+ # Right now out of all response headers we're only taking Content-Type into
+ # account. In the future we might also want to consider the
+ # Content-Disposition header.
+ return mime is not None and 'html' in mime
+
+
+@dc.dataclass(frozen=True)
+class FullHTTPInfo:
+ request_info: RequestInfo
+ response_info: ResponseInfo
+
+ @property
+ def is_likely_a_page(self) -> bool:
+ return is_likely_a_page(self.request_info, self.response_info)
diff --git a/src/hydrilla/proxy/policies/__init__.py b/src/hydrilla/proxy/policies/__init__.py
new file mode 100644
index 0000000..93c3d4f
--- /dev/null
+++ b/src/hydrilla/proxy/policies/__init__.py
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+from .base import PolicyPriority, Policy, PolicyFactory, response_work_data
+
+from .payload import PayloadPolicyFactory
+
+from .payload_resource import PayloadResourcePolicyFactory
+
+from .rule import RuleBlockPolicyFactory, RuleAllowPolicyFactory
+
+from .misc import FallbackAllowPolicy, FallbackBlockPolicy, ErrorBlockPolicy, \
+ MitmItPagePolicyFactory
+
+from .web_ui import WebUIMainPolicyFactory, WebUILandingPolicyFactory
diff --git a/src/hydrilla/proxy/policies/base.py b/src/hydrilla/proxy/policies/base.py
new file mode 100644
index 0000000..967e2c4
--- /dev/null
+++ b/src/hydrilla/proxy/policies/base.py
@@ -0,0 +1,363 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Base defintions for policies for altering HTTP requests.
+#
+# 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.
+
+"""
+.....
+"""
+
+import enum
+import re
+import threading
+import dataclasses as dc
+import typing as t
+
+from abc import ABC, abstractmethod
+from hashlib import sha256
+from base64 import b64encode
+
+import jinja2
+
+from immutables import Map
+
+from ... import translations
+from ... import url_patterns
+from ... import common_jinja_templates
+from .. import state
+from .. import http_messages
+from .. import csp
+
+
+_info_loader = jinja2.PackageLoader(
+ __package__,
+ package_path = 'info_pages_templates'
+)
+_combined_loader = common_jinja_templates.combine_with_loaders([_info_loader])
+_jinja_info_env = jinja2.Environment(
+ loader = _combined_loader,
+ autoescape = jinja2.select_autoescape(['html.jinja']),
+ lstrip_blocks = True,
+ extensions = ['jinja2.ext.i18n', 'jinja2.ext.do']
+)
+_jinja_info_env.globals['url_patterns'] = url_patterns
+_jinja_info_lock = threading.Lock()
+
+
+_jinja_script_loader = jinja2.PackageLoader(
+ __package__,
+ package_path = 'injectable_scripts'
+)
+_jinja_script_env = jinja2.Environment(
+ loader = _jinja_script_loader,
+ autoescape = False,
+ lstrip_blocks = True,
+ extensions = ['jinja2.ext.do']
+)
+_jinja_script_lock = threading.Lock()
+
+def get_script_template(template_file_name: str) -> jinja2.Template:
+ with _jinja_script_lock:
+ return _jinja_script_env.get_template(template_file_name)
+
+
+response_work_data = threading.local()
+
+def response_nonce() -> str:
+ """
+ When called multiple times during consume_response(), each time returns the
+ same unpredictable string unique to this response. The string is used as a
+ nonce for script elements.
+ """
+ return response_work_data.nonce
+
+
+class PolicyPriority(int, enum.Enum):
+ """...."""
+ _ONE = 1
+ _TWO = 2
+ _THREE = 3
+
+
+class MsgProcessOpt(enum.Enum):
+ """...."""
+ MUST = True
+ MUST_NOT = False
+
+
+MessageInfo = t.Union[
+ http_messages.RequestInfo,
+ http_messages.ResponseInfo
+]
+
+
+# We're doing *very* simple doctype matching for now. If a site wanted, it could
+# trick us into getting this wrong.
+doctype_re = re.compile(r'^\s*<!doctype[^>]*>', re.IGNORECASE)
+
+
+UTF8_BOM = b'\xEF\xBB\xBF'
+BOMs = (
+ (UTF8_BOM, 'utf-8'),
+ (b'\xFE\xFF', 'utf-16be'),
+ (b'\xFF\xFE', 'utf-16le')
+)
+
+
+# mypy needs to be corrected:
+# https://stackoverflow.com/questions/70999513/conflict-between-mix-ins-for-abstract-dataclasses/70999704#70999704
+@dc.dataclass(frozen=True) # type: ignore[misc]
+class Policy(ABC):
+ _process_request: t.ClassVar[t.Optional[MsgProcessOpt]] = None
+ _process_response: t.ClassVar[t.Optional[MsgProcessOpt]] = None
+ anticache: t.ClassVar[bool] = True
+
+ priority: t.ClassVar[PolicyPriority]
+
+ haketilo_settings: state.HaketiloGlobalSettings
+
+ @property
+ def current_popup_settings(self) -> state.PopupSettings:
+ return self.haketilo_settings.default_popup_jsallowed
+
+ def should_process_request(
+ self,
+ request_info: http_messages.BodylessRequestInfo
+ ) -> bool:
+ return self._process_request == MsgProcessOpt.MUST
+
+ def should_process_response(
+ self,
+ request_info: http_messages.RequestInfo,
+ response_info: http_messages.AnyResponseInfo
+ ) -> bool:
+ if self._process_response is not None:
+ return self._process_response.value
+
+ return (self.current_popup_settings.popup_enabled and
+ http_messages.is_likely_a_page(request_info, response_info))
+
+ def _get_info_template(self, template_file_name: str) -> jinja2.Template:
+ with _jinja_info_lock:
+ chosen_locale = self.haketilo_settings.locale
+ if chosen_locale not in translations.supported_locales:
+ chosen_locale = None
+
+ if chosen_locale is None:
+ chosen_locale = translations.default_locale
+
+ trans = translations.translation(chosen_locale)
+ _jinja_info_env.install_gettext_translations(trans) # type: ignore
+ return _jinja_info_env.get_template(template_file_name)
+
+
+ def _csp_to_clear(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Union[t.Sequence[str], t.Literal['all']]:
+ return ()
+
+ def _csp_to_add(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Mapping[str, t.Sequence[str]]:
+ return Map()
+
+ def _csp_to_extend(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Mapping[str, t.Sequence[str]]:
+ if (self.current_popup_settings.popup_enabled and
+ http_info.is_likely_a_page):
+ nonce_source = f"'nonce-{response_nonce()}'"
+ directives = (
+ 'script-src',
+ 'script-src-elem',
+ 'style-src',
+ 'frame-src'
+ )
+ return dict((directive, [nonce_source]) for directive in directives)
+ else:
+ return Map()
+
+ def _modify_response_headers(self, http_info: http_messages.FullHTTPInfo) \
+ -> http_messages.IHeaders:
+ csp_to_clear = self._csp_to_clear(http_info)
+ csp_to_add = self._csp_to_add(http_info)
+ csp_to_extend = self._csp_to_extend(http_info)
+
+ if len(csp_to_clear) + len(csp_to_extend) + len(csp_to_add) == 0:
+ return http_info.response_info.headers
+
+ return csp.modify(
+ headers = http_info.response_info.headers,
+ clear = csp_to_clear,
+ add = csp_to_add,
+ extend = csp_to_extend
+ )
+
+ def _modify_response_document(
+ self,
+ http_info: http_messages.FullHTTPInfo,
+ encoding: t.Optional[str]
+ ) -> t.Union[str, bytes]:
+ popup_settings = self.current_popup_settings
+
+ if popup_settings.popup_enabled:
+ nonce = response_nonce()
+
+ popup_page = self.make_info_page(http_info)
+ if popup_page is None:
+ template = self._get_info_template(
+ 'special_page_info.html.jinja'
+ )
+ popup_page = template.render(
+ url = http_info.request_info.url.orig_url
+ )
+
+ template = get_script_template('popup.js.jinja')
+ popup_script = template.render(
+ popup_page_b64 = b64encode(popup_page.encode()).decode(),
+ nonce_b64 = b64encode(nonce.encode()).decode(),
+ # TODO: add an option to configure popup style in the web UI.
+ # Then start passing the real style value.
+ #popup_style = popup_settings.style.value
+ popup_style = 'D'
+ )
+
+ if encoding is None:
+ encoding = 'utf-8'
+
+ body_bytes = http_info.response_info.body
+ body = body_bytes.decode(encoding, errors='replace')
+
+ match = doctype_re.match(body)
+ doctype_decl_len = 0 if match is None else match.end()
+
+ dotype_decl = body[0:doctype_decl_len]
+ doc_rest = body[doctype_decl_len:]
+ script_tag = f'<script nonce="{nonce}">{popup_script}</script>'
+
+ return dotype_decl + script_tag + doc_rest
+ else:
+ return http_info.response_info.body
+
+ def _modify_response_body(self, http_info: http_messages.FullHTTPInfo) \
+ -> bytes:
+ if not http_info.is_likely_a_page:
+ return http_info.response_info.body
+
+ data = http_info.response_info.body
+
+ _, encoding = http_info.response_info.deduce_content_type()
+
+ # A UTF BOM overrides encoding specified by the header.
+ for bom, encoding_name in BOMs:
+ if data.startswith(bom):
+ encoding = encoding_name
+
+ new_data = self._modify_response_document(http_info, encoding)
+
+ if isinstance(new_data, str):
+ # Appending a three-byte Byte Order Mark (BOM) will force the
+ # browser to decode this as UTF-8 regardless of the 'Content-Type'
+ # header. See
+ # https://www.w3.org/International/tests/repository/html5/the-input-byte-stream/results-basics#precedence
+ new_data = UTF8_BOM + new_data.encode()
+
+ return new_data
+
+ def consume_request(self, request_info: http_messages.RequestInfo) \
+ -> t.Optional[MessageInfo]:
+ # We're not using @abstractmethod because not every Policy needs it and
+ # we don't want to force child classes into implementing dummy methods.
+ raise NotImplementedError(
+ 'This kind of policy does not consume requests.'
+ )
+
+ def consume_response(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[http_messages.ResponseInfo]:
+ try:
+ new_headers = self._modify_response_headers(http_info)
+ new_body = self._modify_response_body(http_info)
+ except Exception as e:
+ # In the future we might want to actually describe eventual errors.
+ # For now, we're just printing the stack trace.
+ import traceback
+
+ error_info_list = traceback.format_exception(
+ type(e),
+ e,
+ e.__traceback__
+ )
+
+ return http_messages.ResponseInfo.make(
+ status_code = 500,
+ headers = (('Content-Type', 'text/plain; charset=utf-8'),),
+ body = '\n'.join(error_info_list).encode()
+ )
+
+ if (new_headers is http_info.response_info.headers and
+ new_body is http_info.response_info.body):
+ return None
+
+ return dc.replace(
+ http_info.response_info,
+ headers = new_headers,
+ body = new_body
+ )
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ return None
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class PolicyFactory(ABC):
+ """...."""
+ builtin: bool
+
+ @abstractmethod
+ def make_policy(self, haketilo_state: state.HaketiloState) \
+ -> t.Optional[Policy]:
+ """...."""
+ ...
+
+ def __lt__(self, other: 'PolicyFactory'):
+ """...."""
+ return sorting_keys.get(self.__class__.__name__, 999) < \
+ sorting_keys.get(other.__class__.__name__, 999)
+
+sorting_order = (
+ 'WebUIMainPolicyFactory',
+ 'WebUILandingPolicyFactory',
+
+ 'MitmItPagePolicyFactory',
+
+ 'PayloadResourcePolicyFactory',
+
+ 'PayloadPolicyFactory',
+
+ 'RuleBlockPolicyFactory',
+ 'RuleAllowPolicyFactory',
+
+ 'FallbackPolicyFactory'
+)
+
+sorting_keys = Map((cls, name) for name, cls in enumerate(sorting_order))
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja
new file mode 100644
index 0000000..9268c92
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/info_base.html.jinja
@@ -0,0 +1,97 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy info page with information about other page - base template.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+
+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" %}
+
+{% macro hkt_doc_link(page_name) %}
+ {% set doc_url = 'https://hkt.mitm.it/doc/' ~ page_name %}
+ {{ doc_link(doc_url) }}
+{% endmacro %}
+
+{% block style %}
+ {{ super() }}
+
+ #main {
+ padding: 0 10px;
+ }
+{% endblock %}
+
+{% block head %}
+ {{ super() }}
+
+ <title>{{ _('info.base.title') }}</title>
+{% endblock head %}
+
+{% block main %}
+ <h3>
+ {{ _('info.base.heading.page_info') }}
+ {{ hkt_doc_link('popup') }}
+ </h3>
+
+ {{ label(_('info.base.page_url_label')) }}
+
+ <p>
+ {{ url }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+
+ {% call label(_('info.base.page_policy_label')) %}
+ {{ hkt_doc_link('policy_selection') }}
+ {% endcall %}
+
+ <p class="has-colored-links">
+ {% block site_policy required %}{% endblock %}
+ </p>
+
+ {% block main_rest %}
+ {% endblock %}
+
+ {% block options %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('info.base.more_config_options_label')) }}
+
+ {% set site_pattern = url_patterns.pattern_for_domain(url)|urlencode %}
+ {% set page_pattern = url_patterns.normalize_pattern(url)|urlencode %}
+
+ {%
+ for pattern, hkt_url_fmt, but_text in [
+ (site_pattern, 'https://hkt.mitm.it/rules/viewbypattern?pattern={}',
+ _('info.base.this_site_script_blocking_button')),
+
+ (site_pattern, 'https://hkt.mitm.it/import?pattern={}',
+ _('info.base.this_site_payload_button')),
+
+ (page_pattern, 'https://hkt.mitm.it/rules/viewbypattern?pattern={}',
+ _('info.base.this_page_script_blocking_button')),
+
+ (page_pattern, 'https://hkt.mitm.it/import?pattern={}',
+ _('info.base.this_page_payload_button'))
+ ]
+ %}
+ {% set hkt_url = hkt_url_fmt.format(pattern) %}
+ {% set classes = "green-button block-with-bottom-margin" %}
+ <a class="{{classes}}" href="{{ hkt_url }}" target="_blank">
+ {{ but_text }}
+ </a>
+ {% endfor %}
+ {% endblock options %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja
new file mode 100644
index 0000000..181b219
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/js_error_blocked_info.html.jinja
@@ -0,0 +1,22 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy info page with information about page with JS blocked after an error.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#}
+{% extends "info_base.html.jinja" %}
+
+{% block site_policy %}
+ {{ _('info.js_error_blocked.html')|safe }}
+{% endblock %}
+
+{% block main_rest %}
+ {% if settings.advanced_user %}
+ {{ label(_('info.js_error_blocked.stacktrace')) }}
+
+ {% call verbatim() %}{{ traceback }}{% endcall %}
+ {% endif %}
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/js_fallback_allowed_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/js_fallback_allowed_info.html.jinja
new file mode 100644
index 0000000..71f3151
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/js_fallback_allowed_info.html.jinja
@@ -0,0 +1,14 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy info page with information about page with JS allowed by default policy.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#}
+{% extends "info_base.html.jinja" %}
+
+{% block site_policy %}
+ {{ _('info.js_fallback_allowed') }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/js_fallback_blocked_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/js_fallback_blocked_info.html.jinja
new file mode 100644
index 0000000..1b4ad51
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/js_fallback_blocked_info.html.jinja
@@ -0,0 +1,15 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy info page with information about page with JS blocked by default policy.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#}
+{% extends "info_base.html.jinja" %}
+
+{% block site_policy %}
+ {{ _('info.js_fallback_blocked') }}
+ {{ hkt_doc_link('script_blocking') }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/js_rule_allowed_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/js_rule_allowed_info.html.jinja
new file mode 100644
index 0000000..fe74602
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/js_rule_allowed_info.html.jinja
@@ -0,0 +1,14 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy info page with information about page with JS allowed by a rule.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#}
+{% extends "js_rule_info.html.jinja" %}
+
+{% block site_policy %}
+ {{ format_html_with_rule_url(_('info.js_allowed.html.rule{url}_is_used')) }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/js_rule_blocked_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/js_rule_blocked_info.html.jinja
new file mode 100644
index 0000000..3f396a8
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/js_rule_blocked_info.html.jinja
@@ -0,0 +1,15 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy info page with information about page with JS blocked by a rule.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#}
+{% extends "js_rule_info.html.jinja" %}
+
+{% block site_policy %}
+ {{ format_html_with_rule_url(_('info.js_blocked.html.rule{url}_is_used')) }}
+ {{ hkt_doc_link('script_blocking') }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/js_rule_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/js_rule_info.html.jinja
new file mode 100644
index 0000000..1c0c662
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/js_rule_info.html.jinja
@@ -0,0 +1,39 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy info page with information about page with JS blocked or allowed by a
+rule - template for firther extending.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+
+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 "info_base.html.jinja" %}
+
+{% macro format_html_with_rule_url(msg_fmt) %}
+ {% set url_fmt = 'https://hkt.mitm.it/rules/viewbypattern?pattern={pattern}' %}
+ {{ msg_fmt.format(url=url_fmt.format(pattern=pattern)|e)|safe }}
+{% endmacro %}
+
+{% block main_rest %}
+ <div class="horizontal-separator"></div>
+
+ {% call label(_('info.rule.matched_pattern_label')) %}
+ {{ hkt_doc_link('url_patterns') }}
+ {% endcall %}
+
+ <p>
+ {{ pattern }}
+ </p>
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja
new file mode 100644
index 0000000..e66e685
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/payload_info.html.jinja
@@ -0,0 +1,50 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy info page with information about page with payload.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+
+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 "info_base.html.jinja" %}
+
+{% macro format_html_with_package_identifier_and_url(msg_fmt) %}
+ {% set package_identifier = payload_data.mapping_identifier|e %}
+ {% set url_fmt = 'https://hkt.mitm.it/package/viewbypayload/{payload_id}/{package_identifier}' %}
+ {%
+ set url = url_fmt.format(
+ payload_id = payload_data.ref.id,
+ package_identifier = package_identifier
+ )
+ %}
+ {{ msg_fmt.format(identifier=package_identifier, url=url|e)|safe }}
+{% endmacro %}
+
+{% block site_policy %}
+ {% set fmt = _('info.payload.html.package_{identifier}{url}_is_used') %}
+ {{ format_html_with_package_identifier_and_url(fmt) }}
+{% endblock %}
+
+{% block main_rest %}
+ <div class="horizontal-separator"></div>
+
+ {% call label(_('info.payload.matched_pattern_label')) %}
+ {{ hkt_doc_link('url_patterns') }}
+ {% endcall %}
+
+ <p>
+ {{ payload_data.pattern }}
+ </p>
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/info_pages_templates/special_page_info.html.jinja b/src/hydrilla/proxy/policies/info_pages_templates/special_page_info.html.jinja
new file mode 100644
index 0000000..2f7a9d3
--- /dev/null
+++ b/src/hydrilla/proxy/policies/info_pages_templates/special_page_info.html.jinja
@@ -0,0 +1,17 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy info page with information about page handled by special policy.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#}
+{% extends "info_base.html.jinja" %}
+
+{% block site_policy %}
+ {{ _('info.special_page') }}
+{% endblock %}
+
+{% block options %}
+{% endblock %}
diff --git a/src/hydrilla/proxy/policies/injectable_scripts/page_init_script.js.jinja b/src/hydrilla/proxy/policies/injectable_scripts/page_init_script.js.jinja
new file mode 100644
index 0000000..f3398ef
--- /dev/null
+++ b/src/hydrilla/proxy/policies/injectable_scripts/page_init_script.js.jinja
@@ -0,0 +1,151 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later
+
+Haketilo page APIs code template.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2021,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.
+
+As additional permission under GNU GPL version 3 section 7, you
+may distribute forms of that code without the copy of the GNU
+GPL normally required by section 4, provided you include this
+license notice and, in case of non-source distribution, a URL
+through which recipients can access the Corresponding Source.
+If you modify file(s) with this exception, you may extend this
+exception to your version of the file(s), but you are not
+obligated to do so. If you do not wish to do so, delete this
+exception statement from your version.
+
+As a special exception to the GPL, any HTML file which merely
+makes function calls to this code, and for that purpose
+includes it by reference shall be deemed a separate work for
+copyright law purposes. If you modify this code, you may extend
+this exception to your version of the code, but you are not
+obligated to do so. If you do not wish to do so, delete this
+exception statement from your version.
+
+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.
+#}
+
+(function(){
+ /*
+ * Snapshot some variables that other code could theoretically redefine
+ * later. We're not making the effort to protect from redefinition of
+ * prototype properties right now.
+ */
+ const console = window.console;
+ const fetch = window.fetch;
+ const JSON = window.JSON;
+ const URL = window.URL;
+ const Array = window.Array;
+ const Uint8Array = window.Uint8Array;
+ const CustomEvent = window.CustomEvent;
+ const window_dispatchEvent = window.dispatchEvent;
+
+ /* Get values from the proxy. */
+ function decode_jinja(str) {
+ return decodeURIComponent(atob(str));
+ }
+ const unique_token = decode_jinja("{{ unique_token_encoded }}");
+ const assets_base_url = decode_jinja("{{ assets_base_url_encoded }}");
+ window.haketilo_version = JSON.parse(
+ decode_jinja("{{ haketilo_version }}")
+ );
+
+ /* Make it possible to serialize an Error object. */
+ function error_data_jsonifiable(error) {
+ const jsonifiable = {};
+ for (const property of ["name", "message", "fileName", "lineNumber"])
+ jsonifiable[property] = error[property];
+
+ return jsonifiable;
+ }
+
+ /* Make it possible to serialize a Uint8Array. */
+ function uint8_to_hex(array) {
+ return [...array].map(b => ("0" + b.toString(16)).slice(-2)).join("");
+ }
+
+ async function on_unrestricted_http_request(event) {
+ const name = "haketilo_CORS_bypass";
+
+ if (typeof event.detail !== "object" ||
+ event.detail === null ||
+ typeof event.detail.id !== "string" ||
+ typeof event.detail.data !== "string") {
+ console.error(`Unrestricted HTTP: Invalid detail.`, event.detail);
+ return;
+ }
+
+ try {
+ const data = JSON.parse(event.detail.data);
+
+ const params = new URLSearchParams({
+ target_url: data.url,
+ extra_headers: JSON.stringify(data.headers || [])
+ });
+ const replacement_url = assets_base_url + "api/unrestricted_http";
+ const replacement_url_obj = new URL(replacement_url);
+ replacement_url_obj.search = params;
+
+ const response = await fetch(replacement_url_obj.href, data.init);
+ const response_buffer = await response.arrayBuffer();
+
+ const true_headers_serialized =
+ response.headers.get("x-haketilo-true-headers");
+
+ if (true_headers_serialized === null)
+ throw new Error("Unrestricted HTTP: The 'X-Haketilo-True-Headers' HTTP response header is missing. Are we connected to Haketilo proxy?")
+
+ const true_headers = JSON.parse(
+ decodeURIComponent(true_headers_serialized)
+ );
+
+ const bad_format_error_msg =
+ "Unrestricted HTTP: The 'X-Haketilo-True-Headers' HTTP response header has invalid format.";
+
+ if (!Array.isArray(true_headers))
+ throw new Error(bad_format_error_msg);
+
+ for (const [header, value] of true_headers) {
+ if (typeof header !== "string" || typeof value !== "string")
+ throw new Error(bad_format_error_msg);
+ }
+
+ var result = {
+ status: response.status,
+ statusText: response.statusText,
+ headers: true_headers,
+ body: uint8_to_hex(new Uint8Array(response_buffer))
+ };
+ } catch(e) {
+ var result = {error: error_data_jsonifiable(e)};
+ }
+
+ const response_name = `${name}-${event.detail.id}`;
+ const detail = JSON.stringify(result);
+ window_dispatchEvent(new CustomEvent(response_name, {detail}));
+ }
+
+ window.addEventListener(
+ "haketilo_CORS_bypass",
+ on_unrestricted_http_request
+ );
+})();
diff --git a/src/hydrilla/proxy/policies/injectable_scripts/popup.js.jinja b/src/hydrilla/proxy/policies/injectable_scripts/popup.js.jinja
new file mode 100644
index 0000000..593673b
--- /dev/null
+++ b/src/hydrilla/proxy/policies/injectable_scripts/popup.js.jinja
@@ -0,0 +1,221 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later
+
+Haketilo popup display script.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2021,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.
+
+As additional permission under GNU GPL version 3 section 7, you
+may distribute forms of that code without the copy of the GNU
+GPL normally required by section 4, provided you include this
+license notice and, in case of non-source distribution, a URL
+through which recipients can access the Corresponding Source.
+If you modify file(s) with this exception, you may extend this
+exception to your version of the file(s), but you are not
+obligated to do so. If you do not wish to do so, delete this
+exception statement from your version.
+
+As a special exception to the GPL, any HTML file which merely
+makes function calls to this code, and for that purpose
+includes it by reference shall be deemed a separate work for
+copyright law purposes. If you modify this code, you may extend
+this exception to your version of the code, but you are not
+obligated to do so. If you do not wish to do so, delete this
+exception statement from your version.
+
+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.
+#}
+
+(function(){
+ document.currentScript.remove();
+
+ /*
+ * To slightly decrease the chance of accidental popup breakage we snapshot
+ * methods that other code might redefine.
+ */
+ function get_setter(obj, name) {
+ return Object.getOwnPropertyDescriptor(obj, name).set;
+ }
+
+ const ElementPrototype = [0, 0, 0]
+ .reduce(n => Object.getPrototypeOf(n), document.documentElement);
+
+ const prepend_fun = ElementPrototype.prepend;
+ const setattr_fun = ElementPrototype.setAttribute;
+ const remove_fun = ElementPrototype.remove;
+ const setinner_fun = get_setter(ElementPrototype, "innerHTML");
+ const open_fun = window.open;
+
+ const shortcut = "HKT";
+ const nonce = atob("{{nonce_b64}}");
+ const popup_style = "{{popup_style}}";
+ const popup_html = atob("{{popup_page_b64}}");
+ const popup_container = document.createElement("div");
+ const popup_frame = document.createElement("iframe");
+
+ function make_style(styles_obj) {
+ return Object.entries(styles_obj)
+ .map(([key, val]) => `${key}: ${val} !important`)
+ .join(';');
+ }
+
+ const frame_style = make_style({
+ "position": "absolute",
+ "left": "50%",
+ "top": "50%",
+ "transform": "translate(-50%, -50%)",
+ "display": "block",
+ "visibility": "visible",
+ "min-width": "initial",
+ "width": "600px",
+ "max-width": "calc(100vw - 20px)",
+ "min-height": "initial",
+ "height": "700px",
+ "max-height": "calc(100vh - 20px)",
+ "background-color": "#fff",
+ "opacity": "100%",
+ "margin": 0,
+ "padding": 0,
+ "border": "none",
+ "border-radius": "5px"
+ });
+
+ const container_style = make_style({
+ "position": "fixed",
+ "left": "0",
+ "top": "0",
+ "transform": "initial",
+ "z-index": 2147483647,
+ "display": "block",
+ "visibility": "visible",
+ "min-width": "100vw",
+ "max-width": "100vw",
+ "min-height": "100vh",
+ "max-height": "100vh",
+ "background-color": "#0008",
+ "opacity": "100%",
+ "margin": 0,
+ "padding": 0,
+ "border": "none",
+ "border-radius": 0
+ });
+
+ const popup_blob_opts = {type: "text/html;charset=UTF-8"};
+ const popup_blob = new Blob([popup_html], popup_blob_opts);
+ const popup_url = URL.createObjectURL(popup_blob);
+
+ function show_popup_dialog() {
+ setattr_fun.call(popup_frame, "srcdoc", popup_html);
+ setattr_fun.call(popup_frame, "nonce", nonce);
+ setattr_fun.call(popup_frame, "style", frame_style);
+
+ setattr_fun.call(popup_container, "style", container_style);
+ setinner_fun.call(popup_container, "");
+ prepend_fun.call(popup_container, popup_frame);
+
+ prepend_fun.call(document.body, popup_container);
+ }
+
+ let popup_newtab_wanted = false;
+
+ function show_popup_newtab() {
+ /*
+ * We cannot open popup directly here because browsers block window
+ * creation attempts from "keypress" event handlers. Instead, we set a
+ * flag to have "click" event handler open the popup.
+ */
+ popup_newtab_wanted = true;
+ console.info(`You typed "${shortcut}". Please click anywhere on the page to show Haketilo page information.`);
+ }
+
+ function show_popup() {
+ if (popup_style === "T") {
+ show_popup_newtab();
+ } else {
+ /* popup_syle === "D" */
+ show_popup_dialog();
+ }
+ }
+
+ function hide_popup_dialog() {
+ remove_fun.call(popup_container);
+ }
+
+ let letters_matched = 0;
+
+ function matches_previous(letter) {
+ return letters_matched > 0 && letter === shortcut[letters_matched - 1];
+ }
+
+ function match_letter(letter) {
+ if (letter !== shortcut[letters_matched] && !matches_previous(letter))
+ letters_matched = 0;
+
+ if (letter === shortcut[letters_matched]) {
+ if (++letters_matched === shortcut.length) {
+ letters_matched = 0;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ function consume_keypress(event) {
+ if (!event.isTrusted)
+ return;
+
+ if (match_letter(event.key))
+ show_popup();
+ }
+
+ function cancel_event(event) {
+ event.stopImmediatePropagation();
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+ function consume_click(event) {
+ if (!event.isTrusted)
+ return;
+
+ if (popup_style === "T") {
+ if (popup_newtab_wanted) {
+ popup_newtab_wanted = false;
+ cancel_event(event);
+ window.open(
+ popup_url,
+ "_blank",
+ "popup,width=600px,height=700px"
+ );
+ }
+ } else {
+ /* popup_syle === "D" */
+ if (event.target === popup_container) {
+ hide_popup_dialog();
+ cancel_event(event);
+ }
+ }
+ }
+
+ document.addEventListener("keypress", consume_keypress, {capture: true});
+ document.addEventListener("click", consume_click, {capture: true});
+})();
diff --git a/src/hydrilla/proxy/policies/misc.py b/src/hydrilla/proxy/policies/misc.py
new file mode 100644
index 0000000..e789b29
--- /dev/null
+++ b/src/hydrilla/proxy/policies/misc.py
@@ -0,0 +1,110 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Miscellaneous policies.
+#
+# 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.
+
+"""
+.....
+"""
+
+import enum
+import traceback as tb
+import dataclasses as dc
+import typing as t
+
+from abc import ABC, abstractmethod
+
+from .. import state
+from .. import http_messages
+from . import base
+from .rule import AllowPolicy, BlockPolicy
+
+
+class FallbackAllowPolicy(AllowPolicy):
+ priority = base.PolicyPriority._ONE
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ template = self._get_info_template(
+ 'js_fallback_allowed_info.html.jinja'
+ )
+ return template.render(url=http_info.request_info.url.orig_url)
+
+
+class FallbackBlockPolicy(BlockPolicy):
+ priority = base.PolicyPriority._ONE
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ template = self._get_info_template(
+ 'js_fallback_blocked_info.html.jinja'
+ )
+ return template.render(url=http_info.request_info.url.orig_url)
+
+
+@dc.dataclass(frozen=True)
+class ErrorBlockPolicy(BlockPolicy):
+ error: Exception
+
+ @property
+ def traceback(self) -> str:
+ lines = tb.format_exception(None, self.error, self.error.__traceback__)
+ return ''.join(lines)
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ template = self._get_info_template('js_error_blocked_info.html.jinja')
+ return template.render(
+ url = http_info.request_info.url.orig_url,
+ settings = self.haketilo_settings,
+ traceback = self.traceback
+ )
+
+
+class MitmItPagePolicy(base.Policy):
+ """
+ A special policy class for handling of the magical mitm.it domain. It causes
+ request and response not to be modified in any way and also (unlike
+ FallbackAllowPolicy) prevents them from being streamed.
+ """
+ _process_request = base.MsgProcessOpt.MUST
+ _process_response = base.MsgProcessOpt.MUST
+ anticache = False
+
+ priority = base.PolicyPriority._THREE
+
+ def consume_request(self, request_info: http_messages.RequestInfo) -> None:
+ return None
+
+ def consume_response(self, http_info: http_messages.FullHTTPInfo) -> None:
+ return None
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class MitmItPagePolicyFactory(base.PolicyFactory):
+ builtin: bool = True
+
+ def make_policy(self, haketilo_state: state.HaketiloState) \
+ -> MitmItPagePolicy:
+ return MitmItPagePolicy(haketilo_state.get_settings())
diff --git a/src/hydrilla/proxy/policies/payload.py b/src/hydrilla/proxy/policies/payload.py
new file mode 100644
index 0000000..3660eac
--- /dev/null
+++ b/src/hydrilla/proxy/policies/payload.py
@@ -0,0 +1,271 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Policies for applying payload injections to HTTP requests.
+#
+# 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.
+
+"""
+.....
+"""
+
+import dataclasses as dc
+import typing as t
+
+from urllib.parse import urlencode
+
+from itsdangerous.url_safe import URLSafeSerializer
+import bs4 # type: ignore
+
+from ...exceptions import HaketiloException
+from ...url_patterns import ParsedUrl
+from .. import csp
+from .. import state
+from .. import http_messages
+from . import base
+
+@dc.dataclass(frozen=True) # type: ignore[misc]
+class PayloadAwarePolicy(base.Policy):
+ """...."""
+ payload_data: state.PayloadData
+
+ def _assets_base_url(self, url: ParsedUrl) -> str:
+ token = self.payload_data.unique_token
+
+ base_path_segments = (*self.payload_data.pattern_path_segments, token)
+
+ return f'{url.url_without_path}/{"/".join(base_path_segments)}/'
+
+ def _payload_details_to_signed_query_string(
+ self,
+ _salt: str,
+ **extra_keys: str
+ ) -> str:
+ params: t.Mapping[str, str] = {
+ 'payload_id': self.payload_data.ref.id,
+ **extra_keys
+ }
+
+ serializer = URLSafeSerializer(self.payload_data.global_secret, _salt)
+
+ return urlencode({'details': serializer.dumps(params)})
+
+
+@dc.dataclass(frozen=True) # type: ignore[misc]
+class PayloadAwarePolicyFactory(base.PolicyFactory):
+ """...."""
+ payload_key: state.PayloadKey
+
+ @property
+ def payload_ref(self) -> state.PayloadRef:
+ """...."""
+ return self.payload_key.ref
+
+ def __lt__(self, other: base.PolicyFactory) -> bool:
+ """...."""
+ if isinstance(other, type(self)):
+ return self.payload_key < other.payload_key
+
+ return super().__lt__(other)
+
+
+def block_attr(element: bs4.PageElement, attr_name: str) -> None:
+ """
+ Disable HTML node attributes by prepending `blocked-'. This allows them to
+ still be relatively easily accessed in case they contain some useful data.
+ """
+ blocked_value = element.attrs.pop(attr_name, None)
+
+ while blocked_value is not None:
+ attr_name = f'blocked-{attr_name}'
+ next_blocked_value = element.attrs.pop(attr_name, None)
+ element.attrs[attr_name] = blocked_value
+
+ blocked_value = next_blocked_value
+
+@dc.dataclass(frozen=True)
+class PayloadInjectPolicy(PayloadAwarePolicy):
+ _process_response = base.MsgProcessOpt.MUST
+
+ priority = base.PolicyPriority._TWO
+
+ @property
+ def current_popup_settings(self) -> state.PopupSettings:
+ return self.haketilo_settings.default_popup_payloadon
+
+ def _csp_to_clear(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Sequence[str]:
+ return ['script-src']
+
+ def _csp_to_add(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Mapping[str, t.Sequence[str]]:
+ allowed_origins = [self._assets_base_url(http_info.request_info.url)]
+
+ if self.payload_data.eval_allowed:
+ allowed_origins.append("'unsafe-eval'")
+
+ return {
+ 'script-src': allowed_origins,
+ 'script-src-elem': ["'none'"],
+ 'script-src-attr': ["'none'"]
+ }
+
+ def _script_urls(self, url: ParsedUrl) -> t.Iterable[str]:
+ base_url = self._assets_base_url(url)
+ payload_ref = self.payload_data.ref
+
+ yield base_url + 'api/page_init_script.js'
+
+ for path in payload_ref.get_script_paths():
+ yield base_url + '/'.join(('static', *path))
+
+ def _modify_response_document(
+ self,
+ http_info: http_messages.FullHTTPInfo,
+ encoding: t.Optional[str]
+ ) -> t.Union[bytes, str]:
+ markup = super()._modify_response_document(http_info, encoding)
+ if isinstance(markup, str):
+ encoding = None
+
+ soup = bs4.BeautifulSoup(
+ markup = markup,
+ from_encoding = encoding,
+ features = 'html5lib'
+ )
+
+ # Inject scripts.
+ script_parent = soup.find('body') or soup.find('html')
+ if script_parent is None:
+ return http_info.response_info.body
+
+ for script_url in self._script_urls(http_info.request_info.url):
+ tag = bs4.Tag(name='script', attrs={'src': script_url})
+ script_parent.append(tag)
+
+ # Remove Content Security Policy that could possibly block injected
+ # scripts.
+ for meta in soup.select('head meta[http-equiv]'):
+ header_name = meta.attrs.get('http-equiv', '').lower().strip()
+ if header_name in csp.enforce_header_names:
+ block_attr(meta, 'http-equiv')
+ block_attr(meta, 'content')
+
+ return soup.decode()
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ return self._get_info_template('payload_info.html.jinja').render(
+ url = http_info.request_info.url.orig_url,
+ payload_data = self.payload_data
+ )
+
+
+class _PayloadHasProblemsError(HaketiloException):
+ pass
+
+class AutoPayloadInjectPolicy(PayloadInjectPolicy):
+ priority = base.PolicyPriority._ONE
+
+ def consume_response(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[http_messages.ResponseInfo]:
+ try:
+ if self.payload_data.ref.has_problems():
+ raise _PayloadHasProblemsError()
+
+ self.payload_data.ref.ensure_items_installed()
+
+ return super().consume_response(http_info)
+ except (state.RepoCommunicationError, state.FileInstallationError,
+ _PayloadHasProblemsError) as ex:
+ extra_params: dict[str, str] = {
+ 'next_url': http_info.response_info.url.orig_url
+ }
+ if isinstance(ex, state.FileInstallationError):
+ extra_params['repo_id'] = ex.repo_id
+ extra_params['file_sha256'] = ex.sha256
+
+ query = self._payload_details_to_signed_query_string(
+ _salt = 'auto_install_error',
+ **extra_params
+ )
+
+ redirect_url = 'https://hkt.mitm.it/auto_install_error?' + query
+ msg = 'Error occured when installing payload. Redirecting.'
+
+ return http_messages.ResponseInfo.make(
+ status_code = 303,
+ headers = [('Location', redirect_url)],
+ body = msg.encode()
+ )
+
+
+@dc.dataclass(frozen=True)
+class PayloadSuggestPolicy(PayloadAwarePolicy):
+ _process_request = base.MsgProcessOpt.MUST
+ _process_response = base.MsgProcessOpt.MUST_NOT
+
+ priority = base.PolicyPriority._ONE
+
+ def consume_request(self, request_info: http_messages.RequestInfo) \
+ -> http_messages.ResponseInfo:
+ query = self._payload_details_to_signed_query_string(
+ _salt = 'package_suggestion',
+ next_url = request_info.url.orig_url
+ )
+
+ redirect_url = 'https://hkt.mitm.it/package_suggestion?' + query
+ msg = 'A package was found that could be used on this site. Redirecting.'
+
+ return http_messages.ResponseInfo.make(
+ status_code = 303,
+ headers = [('Location', redirect_url)],
+ body = msg.encode()
+ )
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class PayloadPolicyFactory(PayloadAwarePolicyFactory):
+ """...."""
+ def make_policy(self, haketilo_state: state.HaketiloState) \
+ -> t.Optional[base.Policy]:
+ haketilo_settings = haketilo_state.get_settings()
+
+ try:
+ payload_data = self.payload_ref.get_data()
+ except:
+ return None
+
+ if payload_data.explicitly_enabled:
+ return PayloadInjectPolicy(haketilo_settings, payload_data)
+
+ mode = haketilo_settings.mapping_use_mode
+
+ if mode == state.MappingUseMode.QUESTION:
+ return PayloadSuggestPolicy(haketilo_settings, payload_data)
+
+ if mode == state.MappingUseMode.WHEN_ENABLED:
+ return None
+
+ # mode == state.MappingUseMode.AUTO
+ return AutoPayloadInjectPolicy(haketilo_settings, payload_data)
diff --git a/src/hydrilla/proxy/policies/payload_resource.py b/src/hydrilla/proxy/policies/payload_resource.py
new file mode 100644
index 0000000..0d73242
--- /dev/null
+++ b/src/hydrilla/proxy/policies/payload_resource.py
@@ -0,0 +1,398 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Policies for resolving HTTP requests with local resources.
+#
+# 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.
+
+"""
+We make file resources available to HTTP clients by mapping them
+at:
+ http(s)://<pattern-matching_origin>/<pattern_path>/<token>/
+where <token> is a per-session secret unique for every mapping.
+For example, a payload with pattern like the following:
+ http*://***.example.com/a/b/**
+Could cause resources to be mapped (among others) at each of:
+ https://example.com/a/b/**/Da2uiF2UGfg/
+ https://www.example.com/a/b/**/Da2uiF2UGfg/
+ http://gnome.vs.kde.example.com/a/b/**/Da2uiF2UGfg/
+
+Unauthorized web pages running in the user's browser are exected to be
+unable to guess the secret. This way we stop them from spying on the
+user and from interfering with Haketilo's normal operation.
+
+This is only a soft prevention method. With some mechanisms
+(e.g. service workers), under certain scenarios, it might be possible
+to bypass it. Thus, to make the risk slightly smaller, we also block
+the unauthorized accesses that we can detect.
+
+Since a web page authorized to access the resources may only be served
+when the corresponding mapping is enabled (or AUTO mode is on), we
+consider accesses to non-enabled mappings' resources a security breach
+and block them by responding with 403 Forbidden.
+"""
+
+import dataclasses as dc
+import typing as t
+import json
+
+from base64 import b64encode
+from urllib.parse import quote, parse_qs, urlparse, urlencode, urljoin
+
+from ...translations import smart_gettext as _
+from ...url_patterns import ParsedUrl
+from ...versions import haketilo_version
+from .. import state
+from .. import http_messages
+from . import base
+from .payload import PayloadAwarePolicy, PayloadAwarePolicyFactory
+
+
+def encode_string_for_js(string: str) -> str:
+ return b64encode(quote(string).encode()).decode()
+
+
+AnyValue = t.TypeVar('AnyValue', bound=object)
+
+def header_keys(headers: t.Iterable[tuple[str, AnyValue]]) -> frozenset[str]:
+ return frozenset(header.lower() for header, _ in headers)
+
+def _merge_headers(
+ standard_headers: t.Iterable[tuple[str, t.Optional[str]]],
+ overridable_headers_keys: frozenset[str],
+ native_headers: http_messages.IHeaders,
+ extra_headers: t.Iterable[tuple[str, str]]
+) -> t.Iterable[tuple[str, str]]:
+ standard_keys = header_keys(standard_headers)
+ standard_iterator = iter(standard_headers)
+ native_keys = header_keys(native_headers.items())
+
+ selected_base: list[tuple[str, str]] = []
+ processed: set[str] = set()
+
+ for header, _ in native_headers.items():
+ header_l = header.lower()
+
+ if header_l in processed or header_l not in standard_keys:
+ continue
+
+ for standard_header_l, chosen_value in standard_iterator:
+ if standard_header_l not in native_keys:
+ if chosen_value is not None:
+ selected_base.append((standard_header_l, chosen_value))
+ elif standard_header_l == header_l:
+ processed.add(header_l)
+
+ if header_l in overridable_headers_keys:
+ chosen_value = native_headers.get(header_l, chosen_value)
+
+ if chosen_value is not None:
+ selected_base.append((header, chosen_value))
+
+ break
+
+ for standard_header_l, standard_value in standard_iterator:
+ if standard_value is not None:
+ selected_base.append((standard_header_l, standard_value))
+
+ extra_keys = header_keys(extra_headers)
+ extra_iterator = iter(extra_headers)
+
+ result: list[tuple[str, str]] = []
+ processed = set()
+
+ for header, value in selected_base:
+ header_l = header.lower()
+
+ if header_l in processed:
+ continue
+
+ if header_l in extra_keys:
+ for extra_header, extra_value in extra_iterator:
+ extra_header_l = extra_header.lower()
+
+ processed.add(extra_header_l)
+
+ result.append((extra_header, extra_value))
+
+ if extra_header_l == header_l:
+ break
+ else:
+ result.append((header, value))
+
+ result.extend(extra_iterator)
+
+ return result
+
+request_standard_headers: t.Iterable[tuple[str, t.Optional[str]]] = (
+ ('user-agent', None),
+ ('accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'),
+ ('accept-language', 'en-US,en;q=0.5'),
+ ('accept-encoding', None),
+ ('dnt', '1'),
+ ('connection', None),
+ ('upgrade-insecure-requests', '1'),
+ ('sec-fetch-dest', 'document'),
+ ('sec-fetch-mode', 'navigate'),
+ ('sec-fetch-site', 'none'),
+ ('sec-fetch-user', '?1'),
+ ('te', 'trailers')
+)
+
+auto_overridable_request_headers = frozenset((
+ 'user-agent',
+ 'accept-language',
+ 'accept-encoding',
+ 'dnt'
+))
+
+def merge_request_headers(
+ native_headers: http_messages.IHeaders,
+ extra_headers: t.Iterable[tuple[str, str]]
+) -> t.Iterable[tuple[str, str]]:
+ return _merge_headers(
+ standard_headers = request_standard_headers,
+ overridable_headers_keys = auto_overridable_request_headers,
+ native_headers = native_headers,
+ extra_headers = extra_headers
+ )
+
+response_standard_headers: t.Iterable[tuple[str, t.Optional[str]]] = (
+ ('cache-control', 'max-age=0, private, must-revalidate'),
+ ('connection', None),
+ ('content-length', None),
+ ('content-type', None),
+ ('date', None),
+ ('keep-alive', None),
+ ('server', None)
+)
+
+auto_overridable_response_headers = frozenset(
+ header.lower()
+ for header, value in response_standard_headers
+ if value is None
+)
+
+def merge_response_headers(
+ native_headers: http_messages.IHeaders,
+ extra_headers: t.Iterable[tuple[str, str]]
+) -> t.Iterable[tuple[str, str]]:
+ return _merge_headers(
+ standard_headers = response_standard_headers,
+ overridable_headers_keys = auto_overridable_response_headers,
+ native_headers = native_headers,
+ extra_headers = extra_headers
+ )
+
+
+MessageInfo = t.Union[
+ http_messages.ResponseInfo,
+ http_messages.RequestInfo
+]
+
+@dc.dataclass(frozen=True)
+class PayloadResourcePolicy(PayloadAwarePolicy):
+ _process_request = base.MsgProcessOpt.MUST
+
+ priority = base.PolicyPriority._THREE
+
+ def extract_resource_path(self, request_url: ParsedUrl) -> tuple[str, ...]:
+ # Payload resource pattern has path of the form:
+ # "/some/arbitrary/segments/<per-session_token>/***"
+ #
+ # Corresponding requests shall have path of the form:
+ # "/some/arbitrary/segments/<per-session_token>/actual/resource/path"
+ #
+ # Here we need to extract the "/actual/resource/path" part.
+ segments_to_drop = len(self.payload_data.pattern_path_segments) + 1
+ return request_url.path_segments[segments_to_drop:]
+
+ def should_process_response(
+ self,
+ request_info: http_messages.RequestInfo,
+ response_info: http_messages.AnyResponseInfo
+ ) -> bool:
+ return self.extract_resource_path(request_info.url) \
+ == ('api', 'unrestricted_http')
+
+ def _make_file_resource_response(self, path: tuple[str, ...]) \
+ -> http_messages.ResponseInfo:
+ try:
+ file_data = self.payload_data.ref.get_file_data(path)
+ except state.MissingItemError:
+ return resource_blocked_response
+
+ if file_data is None:
+ return http_messages.ResponseInfo.make(
+ status_code = 404,
+ headers = [('Content-Type', 'text/plain; charset=utf-8')],
+ body =_('api.file_not_found').encode()
+ )
+
+ return http_messages.ResponseInfo.make(
+ status_code = 200,
+ headers = [('Content-Type', file_data.mime_type)],
+ body = file_data.contents
+ )
+
+ def _make_api_response(
+ self,
+ path: tuple[str, ...],
+ request_info: http_messages.RequestInfo
+ ) -> MessageInfo:
+ if path[0] == 'page_init_script.js':
+ template = base.get_script_template('page_init_script.js.jinja')
+
+ token = self.payload_data.unique_token
+ base_url = self._assets_base_url(request_info.url)
+ ver_str = json.dumps(haketilo_version)
+ js = template.render(
+ unique_token_encoded = encode_string_for_js(token),
+ assets_base_url_encoded = encode_string_for_js(base_url),
+ haketilo_version = encode_string_for_js(ver_str)
+ )
+
+ return http_messages.ResponseInfo.make(
+ status_code = 200,
+ headers = [('Content-Type', 'application/javascript')],
+ body = js.encode()
+ )
+
+ if path[0] == 'unrestricted_http':
+ try:
+ assert self.payload_data.cors_bypass_allowed
+
+ params = parse_qs(request_info.url.query)
+ target_url, = params['target_url']
+ extra_headers_str, = params['extra_headers']
+
+ assert urlparse(target_url).scheme in ('http', 'https')
+
+ extra_headers = json.loads(extra_headers_str)
+ assert isinstance(extra_headers, list)
+ for header, value in extra_headers:
+ assert isinstance(header, str)
+ assert isinstance(value, str)
+
+ result_headers = merge_request_headers(
+ native_headers = request_info.headers,
+ extra_headers = extra_headers
+ )
+
+ return http_messages.RequestInfo.make(
+ url = target_url,
+ method = request_info.method,
+ headers = result_headers,
+ body = request_info.body
+ )
+ except:
+ return resource_blocked_response
+ else:
+ return resource_blocked_response
+
+ def consume_request(self, request_info: http_messages.RequestInfo) \
+ -> MessageInfo:
+ resource_path = self.extract_resource_path(request_info.url)
+
+ if resource_path == ():
+ return resource_blocked_response
+ elif resource_path[0] == 'static':
+ return self._make_file_resource_response(resource_path[1:])
+ elif resource_path[0] == 'api':
+ return self._make_api_response(resource_path[1:], request_info)
+ else:
+ return resource_blocked_response
+
+ def consume_response(self, http_info: http_messages.FullHTTPInfo) \
+ -> http_messages.ResponseInfo:
+ """
+ This method shall only be called for responses to unrestricted HTTP API
+ requests. Its purpose is to sanitize response headers and smuggle their
+ original data using an additional header.
+ """
+ serialized = json.dumps([*http_info.response_info.headers.items()])
+ extra_headers = [('X-Haketilo-True-Headers', quote(serialized)),]
+
+ # Greetings, adventurous code dweller! It's amazing you made it that
+ # deep. I hope you're having a good day. If not, read Isaiah 49:15 :)
+ if (300 <= http_info.response_info.status_code < 400):
+ location = http_info.response_info.headers.get('location')
+ if location is not None:
+ orig_params = parse_qs(http_info.request_info.url.query)
+ orig_extra_headers_str, = orig_params['extra_headers']
+
+ new_query = urlencode({
+ 'target_url': location,
+ 'extra_headers': orig_extra_headers_str
+ })
+
+ orig_url = http_info.request_info.url.orig_url
+ new_url = urljoin(orig_url, '?' + new_query)
+
+ extra_headers.append(('location', new_url))
+
+ merged_headers = merge_response_headers(
+ native_headers = http_info.response_info.headers,
+ extra_headers = extra_headers
+ )
+
+ return dc.replace(http_info.response_info, headers=merged_headers)
+
+
+resource_blocked_response = http_messages.ResponseInfo.make(
+ status_code = 403,
+ headers = [('Content-Type', 'text/plain; charset=utf-8')],
+ body = _('api.resource_not_enabled_for_access').encode()
+)
+
+@dc.dataclass(frozen=True)
+class BlockedResponsePolicy(base.Policy):
+ _process_request = base.MsgProcessOpt.MUST
+ _process_response = base.MsgProcessOpt.MUST_NOT
+
+ priority = base.PolicyPriority._THREE
+
+ def consume_request(self, request_info: http_messages.RequestInfo) \
+ -> http_messages.ResponseInfo:
+ return resource_blocked_response
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class PayloadResourcePolicyFactory(PayloadAwarePolicyFactory):
+ """...."""
+ def make_policy(self, haketilo_state: state.HaketiloState) \
+ -> t.Union[PayloadResourcePolicy, BlockedResponsePolicy]:
+ """...."""
+ haketilo_settings = haketilo_state.get_settings()
+
+ try:
+ payload_data = self.payload_ref.get_data()
+ except state.MissingItemError:
+ return BlockedResponsePolicy(haketilo_settings)
+
+ if not payload_data.explicitly_enabled and \
+ haketilo_settings.mapping_use_mode != \
+ state.MappingUseMode.AUTO:
+ return BlockedResponsePolicy(haketilo_settings)
+
+ return PayloadResourcePolicy(haketilo_settings, payload_data)
diff --git a/src/hydrilla/proxy/policies/rule.py b/src/hydrilla/proxy/policies/rule.py
new file mode 100644
index 0000000..e318a7f
--- /dev/null
+++ b/src/hydrilla/proxy/policies/rule.py
@@ -0,0 +1,122 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Policies for blocking and allowing JS in pages fetched with HTTP.
+#
+# 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.
+
+"""
+.....
+"""
+
+import dataclasses as dc
+import typing as t
+
+from ...url_patterns import ParsedPattern
+from .. import csp
+from .. import state
+from ..import http_messages
+from . import base
+
+
+class AllowPolicy(base.Policy):
+ priority = base.PolicyPriority._TWO
+
+
+script_csp_directives = ('script-src', 'script-src-elem', 'script-src-attr')
+
+class BlockPolicy(base.Policy):
+ _process_response = base.MsgProcessOpt.MUST
+
+ priority = base.PolicyPriority._TWO
+
+ @property
+ def current_popup_settings(self) -> state.PopupSettings:
+ return self.haketilo_settings.default_popup_jsblocked
+
+ def _csp_to_clear(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Sequence[str]:
+ return script_csp_directives
+
+ def _csp_to_add(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Mapping[str, t.Sequence[str]]:
+ return dict((d, ["'none'"]) for d in script_csp_directives)
+
+
+@dc.dataclass(frozen=True)
+class RuleAllowPolicy(AllowPolicy):
+ pattern: ParsedPattern
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ template = self._get_info_template('js_rule_allowed_info.html.jinja')
+ return template.render(
+ url = http_info.request_info.url.orig_url,
+ pattern = self.pattern.orig_url
+ )
+
+
+@dc.dataclass(frozen=True)
+class RuleBlockPolicy(BlockPolicy):
+ pattern: ParsedPattern
+
+ def make_info_page(self, http_info: http_messages.FullHTTPInfo) \
+ -> t.Optional[str]:
+ template = self._get_info_template('js_rule_blocked_info.html.jinja')
+ return template.render(
+ url = http_info.request_info.url.orig_url,
+ pattern = self.pattern.orig_url
+ )
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class RulePolicyFactory(base.PolicyFactory):
+ """...."""
+ pattern: ParsedPattern
+
+ def __lt__(self, other: base.PolicyFactory) -> bool:
+ """...."""
+ if type(other) is not type(self):
+ return super().__lt__(other)
+
+ assert isinstance(other, RulePolicyFactory)
+
+ return self.pattern < other.pattern
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class RuleBlockPolicyFactory(RulePolicyFactory):
+ """...."""
+ def make_policy(self, haketilo_state: state.HaketiloState) \
+ -> RuleBlockPolicy:
+ """...."""
+ return RuleBlockPolicy(haketilo_state.get_settings(), self.pattern)
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class RuleAllowPolicyFactory(RulePolicyFactory):
+ """...."""
+ def make_policy(self, haketilo_state: state.HaketiloState) \
+ -> RuleAllowPolicy:
+ """...."""
+ return RuleAllowPolicy(haketilo_state.get_settings(), self.pattern)
diff --git a/src/hydrilla/proxy/policies/web_ui.py b/src/hydrilla/proxy/policies/web_ui.py
new file mode 100644
index 0000000..1c32ea9
--- /dev/null
+++ b/src/hydrilla/proxy/policies/web_ui.py
@@ -0,0 +1,74 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Policy for serving the web UI from within mitmproxy.
+#
+# 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.
+
+"""
+.....
+"""
+
+import dataclasses as dc
+import typing as t
+
+from ...translations import smart_gettext as _
+from .. import state
+from .. import http_messages
+from .. import web_ui
+from . import base
+
+
+@dc.dataclass(frozen=True)
+class WebUIPolicy(base.Policy):
+ _process_request = base.MsgProcessOpt.MUST
+ _process_response = base.MsgProcessOpt.MUST_NOT
+
+ priority = base.PolicyPriority._THREE
+
+ haketilo_state: state.HaketiloState
+ ui_domain: web_ui.UIDomain
+
+ def consume_request(self, request_info: http_messages.RequestInfo) \
+ -> http_messages.ResponseInfo:
+ return web_ui.process_request(
+ request_info = request_info,
+ state = self.haketilo_state,
+ ui_domain = self.ui_domain
+ )
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class WebUIPolicyFactory(base.PolicyFactory):
+ ui_domain: t.ClassVar[web_ui.UIDomain]
+
+ def make_policy(self, haketilo_state: state.HaketiloState) -> WebUIPolicy:
+ haketilo_settings = haketilo_state.get_settings()
+ return WebUIPolicy(haketilo_settings, haketilo_state, self.ui_domain)
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class WebUIMainPolicyFactory(WebUIPolicyFactory):
+ ui_domain = web_ui.UIDomain.MAIN
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class WebUILandingPolicyFactory(WebUIPolicyFactory):
+ ui_domain = web_ui.UIDomain.LANDING_PAGE
diff --git a/src/hydrilla/proxy/self_doc.py b/src/hydrilla/proxy/self_doc.py
new file mode 100644
index 0000000..eb5e9fd
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc.py
@@ -0,0 +1,27 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+import jinja2
+
+from pathlib import Path
+
+
+here = Path(__file__).resolve().parent
+
+loader = jinja2.PackageLoader(__package__, package_path='self_doc')
+
+suffix_len = len('.html.jinja')
+page_names = frozenset(
+ path.name[:-suffix_len]
+ for path in (here / 'self_doc/en_US').glob('*.html.jinja')
+ if path.name != 'doc_base.html.jinja'
+)
+
+available_locales = tuple(
+ path.name
+ for path in (here / 'self_doc').iterdir()
+ if path.is_dir()
+)
diff --git a/src/hydrilla/proxy/self_doc/doc_base.html.jinja b/src/hydrilla/proxy/self_doc/doc_base.html.jinja
new file mode 100644
index 0000000..71842f2
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/doc_base.html.jinja
@@ -0,0 +1,75 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Base template for documentation pages when outputting HTML.
+
+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.
+#}
+{% if doc_output == 'html_hkt_mitm_it' %}
+ {% set doc_base_filename = 'hkt_mitm_it_base.html.jinja' %}
+{% else %}
+ {% set doc_base_filename = 'base.html.jinja' %}
+{% endif %}
+{% extends doc_base_filename %}
+
+{% set sections = namespace(count=0) %}
+
+{% macro section() %}
+ {% if sections.count > 0 %}
+ <div class="horizontal-separator"></div>
+ {% endif %}
+ {% set sections.count = sections.count + 1 %}
+
+ {{ caller()|safe }}
+{% endmacro %}
+
+{% macro doc_page_link(text, page_name) -%}
+ {% if doc_output == 'html_hkt_mitm_it' -%}
+ <a href="{{ url_for('.home_doc', page=page_name) }}">{{ text }}</a>
+ {%- else -%}
+ <a href="{{ page_name ~ '.html' }}">{{ text }}</a>
+ {%- endif %}
+{%- endmacro %}
+
+{% macro hkt_link(text, endpoint_name) -%}
+ {% if doc_output == 'html_hkt_mitm_it' -%}
+ <a href="{{ url_for(endpoint_name, **kwargs) }}">{{ text }}</a>
+ {%- else -%}
+ {{ text }}
+ {%- endif %}
+{%- endmacro %}
+
+{% macro paragraph() %}
+ <p class="has-colored-links">
+ {{ caller()|safe }}
+ </p>
+{% endmacro %}
+
+{% macro big_heading(text) %}
+ <h3>
+ {{ text }}
+ </h3>
+{% endmacro %}
+
+{% macro medium_heading(text) %}
+ <h4>
+ {{ text }}
+ </h4>
+{% endmacro %}
+
+{% macro small_heading(text) %}
+ {{ label(text) }}
+{% endmacro %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/advanced_ui_features.html.jinja b/src/hydrilla/proxy/self_doc/en_US/advanced_ui_features.html.jinja
new file mode 100644
index 0000000..045309b
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/advanced_ui_features.html.jinja
@@ -0,0 +1,70 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page explaining what Haketilo's advanced UI features are.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} Advanced UI features {% endblock %}
+
+{% block main %}
+ {{ big_heading('Haketilo user interface features for advanced users') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ Certain options that may cause a lot of unnecessary confusion to casual
+ Haketilo users have been hidden by default. They can be accessed after
+ enabling advanced UI features on the
+ {{ hkt_link('settings page', 'home.home') }}.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Concept of libraries') }}
+
+ {% call paragraph() %}
+ Haketilo has a concept of 2 types of entities -
+ <span class="bold">packages</span> and
+ <span class="bold">libraries</span>.
+ As explained on the {{ doc_page_link('packages', 'packages') }} page, it's
+ ultimately a package that provides concrete functionality and a casual
+ user does not need to be aware of the existence of libraries.
+ Consequently, with advanced features off the UI does not contain any
+ notion of libraries and even the
+ {{ hkt_link('libraries listing page', 'items.libraries') }} link is
+ removed from the navigation bar.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Selective installation/uninstallation of packages') }}
+
+ {% call paragraph() %}
+ A packages is automatically installed together with all its dependencies
+ when the user enables it.
+ Additionally, whenever some installed Haketilo packages or libraries are
+ found not to be needed anymore, they can be pruned from the
+ {{ hkt_link('settings page', 'home.home') }}.
+ This functionality was deemed sufficient for most users' needs.
+ With advanced features enabled the UI also allows any single package or
+ library not in use to be uninstalled manually and any package or library
+ available from a {{ doc_page_link('repository', 'repositories') }} to be
+ installed without prior enabling.
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/doc_index.html.jinja b/src/hydrilla/proxy/self_doc/en_US/doc_index.html.jinja
new file mode 100644
index 0000000..03f2231
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/doc_index.html.jinja
@@ -0,0 +1,59 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation pages index.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} Documentation index {% endblock %}
+
+{% block main %}
+ {{ big_heading('Haketilo embedded documentation') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ This is the embedded documentation of Haketilo proxy.
+ It contains some basic information aimed to help new users understand how
+ the tool works.
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ {{ doc_page_link('Advanced UI features', 'advanced_ui_features') }}
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('Packages', 'packages') }}
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('Policy selection', 'policy_selection') }}
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('Popup', 'popup') }}
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('Repositories', 'repositories') }}
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('Script blocking', 'script_blocking') }}
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('URL patterns', 'url_patterns') }}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/packages.html.jinja b/src/hydrilla/proxy/self_doc/en_US/packages.html.jinja
new file mode 100644
index 0000000..23e6f45
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/packages.html.jinja
@@ -0,0 +1,218 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page describing the concept of packages in Haketilo.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} Packages {% endblock %}
+
+{% block main %}
+ {{ big_heading('Packages in Haketilo') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ Users can modify web pages by creating, installing and enabling
+ <span class="bold">packages</span>.
+ A package associates {{ doc_page_link('URL patterns', 'url_patterns') }}
+ with payloads (i.e. sets of scripts) that can be injected to pages.
+ For instance, if an enabled package associates pattern
+ <code>https://example.com/***</code> with a script that adds a big
+ "Hello world!" text to the page, this package shall cause "Hello world!"
+ to appear on pages under <code>example.com</code>.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Packages and libraries') }}
+
+ {% call paragraph() %}
+ To make mapping custom JavaScript applications and their dependencies to
+ web pages more manageable, Haketilo defines its own concept of "packages"
+ and "libraries".
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ package - Also called <span class="bold">mapping</span>.
+ It associates URL patterns with libraries.
+ {% endcall %}
+ {% call list_entry() %}
+ library - Sometimes also referred to as
+ <span class="bold">resource</span>.
+ Defines a set of scripts that can be injected together into a page.
+ It can also name other libraries as its dependencies.
+ When injecting scripts of a given library into some page, Haketilo will
+ first inject scripts of all libraries depended on.
+ {% endcall %}
+ {% endcall %}
+
+ {% call paragraph() %}
+ It's ultimately a package that provides concrete functionality to the end
+ user and that can be enabled or disabled.
+ For this reason, a casual user does not even need to be aware of the
+ existence of libraries.
+ Haketilo UI advanced interface features need to be enabled on the
+ {{ hkt_link('settings page', 'home.home') }} for installed libraries to be
+ viewable.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Installing') }}
+
+ {% call paragraph() %}
+ Useful packages prepared by others can be installed from Hydrilla
+ repositories. The repositories can be configured
+ {{ hkt_link('through Haketilo user interface', 'repos.repos') }} as
+ described on
+ {{ doc_page_link('the relevant documentation page', 'repositories') }}.
+ As of Haketilo 3.0-beta1 they need to be manually "refreshed" for new
+ packages from them to be shown in Haketilo.
+ Available packages viewable on the
+ {{ hkt_link('packages listing page', 'items.packages') }} are not
+ immediately installed.
+ This only happens after they are explicitly enabled or automatically
+ enabled (if the user configured Haketilo to do this).
+ {% endcall %}
+
+ {% call paragraph() %}
+ For convenience, users can also create simple packages
+ {{ hkt_link('directly in Haketilo UI', 'import.items_import') }}.
+ A simple form can be used to quickly define a standalone script payload
+ for a set of URL patterns. As of Haketilo 3.0 only simple (i.e.
+ single-library) payloads can be created this way.
+ {% endcall %}
+
+ {% call paragraph() %}
+ It is also possible to import packages from files.
+ For this, a directory of serveable mappings and reasources - as produced
+ by Hydrilla builder and used by Hydrilla server - has to be put into a ZIP
+ archive.
+ It can then be uploaded to Haketilo via its
+ {{ hkt_link('import page', 'import.items_import') }}.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Uninstalling') }}
+
+ {% call paragraph() %}
+ Haketilo tracks dependencies between packages and libraries and
+ automatically determines which of them are no longer needed.
+ These are called <span class="bold">orphans</span> and if present, can be
+ removed from the {{ hkt_link('settings page', 'home.home') }}.
+ A version of package or library that is not being used but is still
+ available from an active repository is not considered an orphan. It
+ automatically becomes one when the repository either stops advertising it
+ as available or gets removed by the user from
+ {{ hkt_link('the repositories list', 'repos.repos') }}.
+ {% endcall %}
+
+ {% call paragraph() %}
+ When advanced UI features are enabled, it is additionally possible to
+ manually uninstall any single package that is not in use at a given
+ moment.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Package contents') }}
+
+ {% call paragraph() %}
+ Each package has an <span class="bold">identifier</span> (built from a
+ restricted set of characters), a <span class="bold">long name</span>, a
+ <span class="bold">description</span>, a <span class="bold">version</span>
+ and almost always a list of <span class="bold">license files</span> and a
+ set of <span class="bold">URL patterns mapped to libraries</span>.
+ In addition there might also be other pieces of information such as
+ required permissions.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Enabling/disabling') }}
+
+ {% call paragraph() %}
+ The user can put package in any of 3 possible states.
+ It can be either <span class="bold">enabled</span>,
+ <span class="bold">disabled</span> or
+ <span class="bold">not configured</span>.
+ {% endcall %}
+
+ {% call paragraph() %}
+ An enabled package always has its payloads injected on pages matched by
+ their patterns (unless some more specific pattern takes precedence on the
+ given page as described on the
+ {{ doc_page_link('policy selection page', 'policy_selection') }}).
+ {% endcall %}
+
+ {% call paragraph() %}
+ A disabled package is always ignored.
+ It has to be manually re-enabled for Haketilo to take it into account
+ again.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Finally, a package that is neither explicitly enabled nor disabled can be
+ treated differently depending on user's choice on the
+ {{ hkt_link('settings page', 'home.home') }}.
+ It is possible to have Haketilo
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ automatically inject such packages' payloads on mathing pages,
+ {% endcall %}
+ {% call list_entry() %}
+ prompt the user on matching pages asking whether the package should be
+ enabled or
+ {% endcall %}
+ {% call list_entry() %}
+ completely ignore non-configured packages.
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Handling multiple versions') }}
+
+ {% call paragraph() %}
+ It is possible to have many versions of the same package or library
+ installed.
+ When this is the case, Haketilo by default uses the newest versions it
+ can.
+ Additionally, if certain package is enabled, its page also allows the user
+ to configure its <span class="bold">pinning</span>.
+ A package can be
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ pinned to use a particular version,
+ {% endcall %}
+ {% call list_entry() %}
+ pinned to use the best version from a particular
+ {{ doc_page_link('repository', 'repositories') }} or
+ {% endcall %}
+ {% call list_entry() %}
+ not pinned at all (best version overall is used).
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/policy_selection.html.jinja b/src/hydrilla/proxy/self_doc/en_US/policy_selection.html.jinja
new file mode 100644
index 0000000..687d2bd
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/policy_selection.html.jinja
@@ -0,0 +1,109 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page describing how Haketilo selects policy to apply to a 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 "doc_base.html.jinja" %}
+
+{% block title %} Policy selection {% endblock %}
+
+{% block main %}
+ {{ big_heading('Page policy selection') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ When a web page is opened, Haketilo is capable of either
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ blocking page's own scripts and
+ {{ doc_page_link('injecting payload', 'packages') }}
+ configured by the user,
+ {% endcall %}
+ {% call list_entry() %}
+ blocking page's own scripts and injecting an automatically-chosen
+ payload that is usable with the page,
+ {% endcall %}
+ {% call list_entry() %}
+ presenting a dialog asking whether to enable an automatically-chosen
+ payload that is usable with the page,
+ {% endcall %}
+ {% call list_entry() %}
+ {{ doc_page_link('blocking', 'script_blocking') }} page's own scripts
+ or
+ {% endcall %}
+ {% call list_entry() %}
+ allowing page's own scripts to execute normally (i.e. not modifying
+ the page in any meaningful way).
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Policy precedence') }}
+
+ {% call paragraph() %}
+ User configures Haketilo's behavior by defining script-blocking and
+ -allowing rules and by adding and enabling packages. Each rule and each
+ package payload has a {{ doc_page_link('URL pattern', 'url_patterns') }}.
+ This pattern determines which pages the policy is compatible with.
+ Patterns also have well-defined specificity. When multiple rules and
+ packages are combatible with given page's URL, the one with the most
+ specific pattern "wins". In case of a tie, payload injection is assumed to
+ take precedence over rule application.
+ {% endcall %}
+
+ {% call paragraph() %}
+ In the absence of suitable rules and enabled packages, Haketilo may
+ consider non-enabled packages that are suitable for use on the
+ currently-visited site. It will either inject package payload
+ automatically, ask the user whether to enable the package or ignore it
+ completely. The user can switch between these 3 behaviors on the Haketilo
+ {{ hkt_link('settings page', 'home.home') }}. Packages that were
+ explicitly marked as disabled will always be ignored. Pattern specificity
+ is also taken into account in case of multiple packages.
+ {% endcall %}
+
+ {% call paragraph() %}
+ When absolutely no explicit policy appears suitable for given page,
+ Haketilo will apply its default script handling behavrior. Whether
+ JavaScript is blocked or allowed by default is also determined by user's
+ choice on the {{ hkt_link('settings page', 'home.home') }}.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Special cases') }}
+
+ {% call paragraph() %}
+ The sites served by Haketilo itself are exempt from all policies. These
+ are <code>http://hkt.mitm.it</code>, <code>https://hkt.mitm.it</code>
+ and <code>http://mitm.it</code>. Additionally, if Haketilo experiences an
+ internal error (e.g. because it could not parse current URL as sent in by
+ the browser), it will try to block page's JavaScript as a security
+ measure.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Internally, Haketilo also has a special high-priority policy for serving
+ files used by payloads and for making its APIs accessible to payload
+ scripts. This is, however, an implementation detail and casual users need
+ not care about it nor understand these nuances.
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/popup.html.jinja b/src/hydrilla/proxy/self_doc/en_US/popup.html.jinja
new file mode 100644
index 0000000..a5ad909
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/popup.html.jinja
@@ -0,0 +1,157 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page describing Haketilo popup.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} Popup {% endblock %}
+
+{% block main %}
+ {{ big_heading('Haketilo popup') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ Taking inspiration from user interface features of browser extensions,
+ Haketilo also offers a popup window for quick interaction with the
+ user. For technical reasons, the popup is presented as part of the web
+ page and behaves slightly differently from those some users might have
+ found in similar tools.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Operating') }}
+
+ {% call paragraph() %}
+ The popup dialog can be opened by typing big letters "HKT" anywhere on the
+ page. It then presents some basic information about the handling of
+ current URL. It also allows the user quickly define new
+ {{ doc_page_link('rules', 'script_blocking') }} or
+ {{ doc_page_link('payloads', 'packages') }} for it. As of Haketilo 3.0,
+ however, the actual configuration is not performed from the popup itself
+ but rather a relevant Haketilo rule/payload definition page is opened in a
+ new tab.
+ {% endcall %}
+
+ {% call paragraph() %}
+ The dialog can be closed by clicking anywhere on the darker area around
+ it. It can then be reopened by typing "HKT" again.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Enabling/disabling') }}
+
+ {% call paragraph() %}
+ Popup is unavailable by default on Haketilo special sites including
+ <code>https://hkt.mitm.it</code>. It can also be disabled independently on
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ pages with JS allowed,
+ {% endcall %}
+ {% call list_entry() %}
+ pages with JS blocked and
+ {% endcall %}
+ {% call list_entry() %}
+ pages with script payload injected.
+ {% endcall %}
+ {% endcall %}
+
+ {% call paragraph() %}
+ This can be configured on the {{ hkt_link('setings page', 'home.home') }}
+ and might be useful to users who are careful about fingerprinting.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Fingerprinting considerations') }}
+
+ {% call paragraph() %}
+ To make the popup available, Haketilo has to inject an additional script
+ to all pages. That makes it easy for pages to determine with certainty
+ that given user is running Haketilo. This has implications for privacy and
+ may also be used by a hostile site to selectively cause annoyance to
+ Haketilo users.
+ {% endcall %}
+
+ {% call paragraph() %}
+ The above problems would be present regardless on pages with
+ Haketilo-injected payloads. I.e. in many cases a site could theoretically
+ find out the user is not accessing it in a normal way. However, the popup
+ also increases fingerprintability when no payload is in use and especially
+ on pages with JavaScript allowed. For this reason, the presence of popup
+ on pages has been made configurable.
+ {% endcall %}
+
+ {% call paragraph() %}
+ It is also worth noting that as of version 3.0 Haketilo does not make
+ guarantees about the browser fingerprint. Despite best efforts, there are
+ still other aspects that might make a Haketilo user distinguishable to a
+ website even when popup is disabled.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Other caveats') }}
+
+ {% call paragraph() %}
+ Some other potential issues related to the popup are described below.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('Interference with the site') }}
+
+ {% call paragraph() %}
+ The popup gets injected by Haketilo into the actual web page. Although
+ care was taken to make accidental breakage unlikely, it might still happen
+ under some specific conditions.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('Interference with other script-blocking tools') }}
+
+ {% call paragraph() %}
+ The popup is driven by a piece of JavaScript code injected by Haketilo to
+ pages. Haketilo by itself makes sure neither the policies specified by the
+ page nor its own script-blocking mechanisms interfere with this particular
+ piece. In spite of that, a browser extension or web browser's own settings
+ might prevent the popup script from executing, making the dialog
+ unavailable.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('URL mismatch') }}
+
+ {% call paragraph() %}
+ Sometimes a page might change parts of its address visible in browser's
+ URL bar. E.g. after opening <code>https://entraide.chatons.org/</code> in
+ the browser we might see <code>https://entraide.chatons.org/en/</code> as
+ the current address even though no reload happened. In addition, some
+ browsers hide URL's traling dash ("/") from the user. Regardless of that,
+ Haketilo's popup always presents the original URL under which the current
+ page was served. Although this the intended behavior, it might cause
+ confusion and therefore has been documented here.
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/repositories.html.jinja b/src/hydrilla/proxy/self_doc/en_US/repositories.html.jinja
new file mode 100644
index 0000000..4cf6d2c
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/repositories.html.jinja
@@ -0,0 +1,128 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page describing the concept of repositories in Haketilo.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} Repositories {% endblock %}
+
+{% block main %}
+ {{ big_heading('Repositories in Haketilo') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ {{ doc_page_link('Packages', 'packages') }} used to alter sites' behavior
+ can be obtained by users from Hydrilla repositories. The repositories to
+ use can be configured from the
+ {{ hkt_link('relevant Haketilo UI page', 'repos.repos') }}. When Haketilo
+ is first run, it only has one entry on that page - the official Hydrilla
+ repository with fixes for sites that normally rely on (often proprietary)
+ JavaScript.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Adding') }}
+
+ {% call paragraph() %}
+ Before experimenting with third-party repositories please bear in mind
+ that a hostile Haketilo package can cause real harm.
+ Scripts injected by Haketilo have access to data on the page, including
+ cookies and passwords you may enter.
+ Do make sure the repositories you are using are trustworthy.
+ {% endcall %}
+
+ {% call paragraph() %}
+ On the {{ hkt_link('repository addition page', 'repos.add_repo') }} the
+ user is expected to supply 2 pieces of information.
+ The <span class="bold">URL</span> of the repository and its
+ <span class="bold">name</span>.
+ The URL is supposed to be provided by repository owner.
+ Then name is only used locally and can be chosen by the user.
+ Allowed are most visible ASCII characters, with possible spaces in-betwen.
+ No 2 repositories can use the same name.
+ {% endcall %}
+
+ {% call paragraph() %}
+ As of Haketilo version 3.0 the user does not need to provide any
+ authentication data (e.g. private keys) because cryptographic signing of
+ packages is not yet supported. This may change in the future.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Removing') }}
+
+ {% call paragraph() %}
+ A repository can be deleted at any time. When this happens,
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ its packages that were in use (e.g. were enabled) retain their state,
+ {% endcall %}
+ {% call list_entry() %}
+ its packages that were installed but not in use become
+ <span class="bold">orphans</span> and can be removed from the
+ {{ hkt_link('settings page', 'home.home') }} and
+ {% endcall %}
+ {% call list_entry() %}
+ its packages that were not installed are forgotten.
+ {% endcall %}
+ {% endcall %}
+
+ {% call paragraph() %}
+ A deleted repository remains viewable from the
+ {{ hkt_link('repositories management page', 'repos.repos') }} for as long
+ as some of its packages remain installed.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Operating') }}
+
+ {% call paragraph() %}
+ Before repository's contents become viewable on the
+ {{ hkt_link('packages listing page', 'items.packages') }}, it needs to be
+ <span class="bold">refreshed</span>.
+ As of Haketilo 3.0-beta1, this action needs to be triggered manually by
+ the user from the configuration page of that repository.
+ Subsequent refreshals are needed every time the user wants to pull package
+ updates.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Repository's name and URL can also be changed from its configuration page.
+ The same requirements for their format hold as when adding a new
+ repository.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Local items') }}
+
+ {% call paragraph() %}
+ When the users installs some additional packages without using a
+ repository, these are considered <span class="bold">local packages</span>.
+ A special "Local items" entry then appears on the
+ {{ hkt_link('repositories management page', 'repos.repos') }}. Local
+ packages that are not in use are automatically considered orhpans.
+ {% endcall %}
+ {% endcall %}
+{% endblock %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/script_blocking.html.jinja b/src/hydrilla/proxy/self_doc/en_US/script_blocking.html.jinja
new file mode 100644
index 0000000..c0a5275
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/script_blocking.html.jinja
@@ -0,0 +1,125 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page describing how Haketilo blocks scripts.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} Script blocking {% endblock %}
+
+{% block main %}
+ {{ big_heading('Script blocking in Haketilo') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ Modern web browsers allow sites to execute software on users'
+ devices. This software is usually written in a language called JavaScript
+ and abbreviated as JS. It can serve various purposes - from small
+ enhancements to deployment of heavy applications inside the
+ browser. Because Haketilo aims to give users control over their web
+ browsing, one of its supported features is blocking of JavaScript
+ execution on per-page and per-site basis.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Besides the casual script-blocking discussed here, Haketilo also blocks
+ page's JavaScript when injecting the user-specified
+ {{ doc_page_link('script payloads', 'packages') }}. That functionality is
+ described on its own documentation page.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Configuring script blocking') }}
+
+ {% call paragraph() %}
+ User can
+ {{
+ hkt_link('define script-blocking and -allowing rules', 'rules.rules')
+ }}
+ using {{ doc_page_link('URL patterns', 'url_patterns') }}. Each such rule
+ tells Haketilo to either block or allow scripts on pages matched by its
+ pattern. Rules with more specific patterns can override those with less
+ specific ones as described on the
+ {{ doc_page_link('policy selection page', 'policy_selection') }}.
+ {% endcall %}
+
+ {% call paragraph() %}
+ As an example, if we want all scripts on english Wikipedia pages to be
+ blocked, we can add a blocking rule with
+ pattern <code>https://en.wikipedia.org/***</code>. If we then wanted to
+ make an exception just for the "List of emoticons" page, we could create
+ an additional allowing rule with
+ <code>https://en.wikipedia.org/wiki/List_of_emoticons</code> as its
+ pattern. It would take effect on that page while all the other english
+ Wikipedia pages would still have their scripts blocked.
+ {% endcall %}
+
+ {% call paragraph() %}
+ It is also possible to configure whether scripts should be blocked by
+ dafault on pages where no explicit rule and no payload is used. The
+ relevant option can be found on Haketilo
+ {{ hkt_link('settings page', 'home.home') }}.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Use with other script-blocking tools') }}
+
+ {% call paragraph() %}
+ Various browsers and browser extension can also be configured to block
+ JavaScript. Haketilo works independently of those tools. If the user
+ desires to have scripts on certain page to execute normally, both Haketilo
+ and other tools must be configured to allow that.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Unlike most similar tools, Haketilo operates outside the web browser. As a
+ result, it is relatively unlikely for Haketilo to cause these to
+ malfunction. At the same time, it is relatively easy to have another
+ script blocker break some Haketilo functionality (e.g. its
+ {{ doc_page_link('popup', 'popup') }}).
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Technical details') }}
+
+ {% call paragraph() %}
+ From technical point of view, Haketilo, as of version 3.0, blocks
+ JavaScript by altering the Content-Security-Policy (abbreviated CSP)
+ headers in HTTP responses. The original CSP directives sent by site are
+ retained, with exception of those which would result in CSP violation
+ reports being sent. Haketilo's own script-blocking directives are then
+ added to produce the final CSP which user's web browser eventually sees.
+ {% endcall %}
+
+ {% call paragraph() %}
+ The above means that neither the scripts that would be blocked by page's
+ own rules nor those that are blocked by Haketilo are going to cause CSP
+ reports to be sent.
+ {% endcall %}
+
+ {% call paragraph() %}
+ In addition, even when a page has JavaScript nominally blocked, Haketilo
+ 3.0 may nevertheless inject into it its own script responsible for making
+ the popup available. The CSP is then modified appropriately to allow only
+ that script to run.
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/self_doc/en_US/url_patterns.html.jinja b/src/hydrilla/proxy/self_doc/en_US/url_patterns.html.jinja
new file mode 100644
index 0000000..f3415c5
--- /dev/null
+++ b/src/hydrilla/proxy/self_doc/en_US/url_patterns.html.jinja
@@ -0,0 +1,409 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Documentation page describing URL patterns understood by Haketilo.
+
+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 "doc_base.html.jinja" %}
+
+{% block title %} URL patterns {% endblock %}
+
+{% block main %}
+ {{ big_heading('Haketio URL patterns') }}
+
+ {% call section() %}
+ {% call paragraph() %}
+ We want to be able to apply different rules and custom scripts for
+ different websites. However, merely specifying "do this for all documents
+ under <code>https://example.com</code>" is not enough. Single site's pages
+ might differ strongly and require different custom scripts to be
+ loaded. Always matching against a full URL like
+ <code>https://example.com/something/somethingelse</code> is also not
+ a good option. It doesn't allow us to properly handle a site that serves
+ similar pages for multiple values substituted for
+ <code>somethingelse</code>.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Employed solution') }}
+
+ {% call paragraph() %}
+ Wildcards are being used to address the problem. Each payload and rule in
+ Haketilo has a URL pattern that specifies to which internet pages it
+ applies. A URL pattern can be as as simple as literal URL in which case it
+ only matches itself. It can also contain wildcards in the form of one or
+ more asterisks (<code>*</code>) that correspond to multiple possible
+ strings occurring in that place.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Wildcards can appear in URL's domain and path that follows it. These 2
+ types of wildcards are handled separately.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('Domain wildcards') }}
+
+ {% call paragraph() %}
+ A domain wildcard takes the form of one, two or three asterisks occurring
+ in place of a single domain name segment at the beginning
+ (left). Depending on the number of asterisks, the meaning is as follows
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ no asterisks (e.g. <code>example.com</code>) - match domain name exactly
+ (e.g. <code>example.com</code>)
+ {% endcall %}
+ {% call list_entry() %}
+ one asterisk (e.g. <code>*.example.com</code>) - match all domains
+ resulting from substituting <code>*</code> with a
+ <span class="bold">single</span> segment (e.g.
+ <code>banana.example.com</code> or <code>pineapple.example.com</code>
+ but <span class="bold">not</span> <code>pineapple.pen.example.com</code>
+ nor <code>example.com</code>)
+ {% endcall %}
+ {% call list_entry() %}
+ two asterisks (e.g. <code>**.example.com</code>) - match all domains
+ resulting from substituting <code>**</code> with
+ <span class="bold">two or more</span> segments (e.g.
+ <code>monad.breakfast.example.com</code> or
+ <code>pure.monad.breakfast.example.com</code> but
+ <span class="bold">not</span> <code>cabalhell.example.com</code> nor
+ <code>example.com</code>)
+ {% endcall %}
+ {% call list_entry() %}
+ three asterisks (e.g. <code>***.example.com</code>) - match all domains
+ resulting from substituting <code>***</code> with
+ <span class="bold">zero or more</span> segments (e.g.
+ <code>hello.parkmeter.example.com</code> or
+ <code>iliketrains.example.com</code> or <code>example.com</code>)
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('Path wildcards') }}
+
+ {% call paragraph() %}
+ A path wildcard takes the form of one, two or three asterisks occurring in
+ place of a single path segment at the end of path (right). Depending on
+ the number of asterisks, the meaning is as follows
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ no asterisks (e.g. <code>/joke/clowns</code>) - match path exactly (e.g.
+ <code>/joke/clowns</code>)
+ {% endcall %}
+ {% call list_entry() %}
+ one asterisk (e.g. <code>/itscalled/*</code>) - match all paths
+ resulting from substituting <code>*</code> with a
+ <span class="bold">single</span> segment (e.g.
+ <code>/itscalled/gnulinux</code> or <code>/itscalled/glamp</code> but
+ <span class="bold">not</span> <code>/itscalled/</code> nor
+ <code>/itscalled/gnu/linux</code>)
+ {% endcall %}
+ {% call list_entry() %}
+ two asterisks (e.g. <code>/another/**</code>) - match all paths
+ resulting from substituting <code>**</code> with
+ <span class="bold">two or more</span> segments (e.g.
+ <code>/another/nsa/backdoor</code> or
+ <code>/another/best/programming/language</code> but
+ <span class="bold">not</span> <code>/another/apibreak</code> nor
+ <code>/another</code>)
+ {% endcall %}
+ {% call list_entry() %}
+ three asterisks (e.g. <code>/mail/dmarc/***</code>) - match all paths
+ resulting from substituting <code>***</code> with
+ <span class="bold">zero or more</span> segments (e.g.
+ <code>/mail/dmarc/spf</code>, <code>/mail/dmarc</code> or
+ <code>/mail/dmarc/dkim/failure</code> but
+ <span class="bold">not</span> <code>/mail/</code>)
+ {% endcall %}
+ {% endcall %}
+
+ {% call paragraph() %}
+ If pattern ends <span class="bold">without</span> a trailing slash, it
+ mathes paths with any number of trailing slashes, including zero. If
+ pattern ends <span class="bold">with</span> a trailing slash, it only
+ mathes paths with one or more trailing slashes. For example,
+ <code>/itscalled/*</code> matches <code>/itscalled/gnulinux</code>,
+ <code>/itscalled/gnulinux/</code> and <code>/itscalled/gnulinux//</code>
+ while <code>/itscalled/*/</code> only matches
+ <code>/itscalled/gnulinux/</code> and <code>/itscalled/gnulinux//</code>
+ out of those three.
+ {% endcall %}
+
+ {% call paragraph() %}
+ If two patterns only differ by the presence of a trailing slash,
+ pattern <span class="bold">with</span> a trailing slash is considered
+ <span class="bold">more specific</span>.
+ {% endcall %}
+
+ {% call paragraph() %}
+ Additionally, any path with literal trailing asterisks is matched by
+ itself, even if such pattern would otherwise be treated as wildcard
+ (e.g. <code>/gobacktoxul/**</code> matches <code>/gobacktoxul/**</code>).
+ This is likely to change in the future and would best not be relied upon.
+ Appending three additional asterisks to path pattern to represent literal
+ asterisks is being considered.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('URL scheme wildcard') }}
+
+ {% call paragraph() %}
+ <code>http://</code> and <code>https://</code> shemes in the URL are
+ matched exactly. However, starting with Haketilo 3.0, it is also possible
+ for scheme pseudo-wildcard of <code>http*://</code> to be used. Use of URL
+ pattern with this scheme is equivalent to the use of 2 separate patterns
+ starting with <code>http://</code> and <code>https://</code>,
+ respectively. For example, pattern <code>http*://example.com</code> shall
+ match both <code>https://example.com</code> and
+ <code>http://example.com</code>.
+ {% endcall %}
+
+ {% call paragraph() %}
+ <code>http*://</code> may be considered not to be a true wildcard but
+ rather an alias for either of the other 2 values. As of Haketilo 3.0, the
+ speicificity of a URL pattern starting with <code>http*://</code> is
+ considered to be the same as that of the corresponding URL pattern
+ starting with <code>http://</code> or <code>https://</code>. In case of a
+ conflict, the order of precedence of such patterns is unspecified. This
+ behavior is likely to change in the future versions of Haketilo.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('Wildcard pattern priorities and querying') }}
+
+ {% call paragraph() %}
+ In case multiple patterns match some URL, the more specific one is
+ preferred. Specificity is considered as follows
+ {% endcall %}
+
+ {% call unordered_list() %}
+ {% call list_entry() %}
+ If patterns only differ in the final path segment, the one with least
+ wildcard asterisks in that segment if preferred.
+ {% endcall %}
+ {% call list_entry() %}
+ If patterns, besides the above, only differ in path length, one with
+ longer path is preferred. Neither final wildcard segment nor trailing
+ dashes account for path length.
+ {% endcall %}
+ {% call list_entry() %}
+ If patterns, besides the above, only differ in the initial domain
+ segment, one with least wildcard asterisks in that segment is preferred.
+ {% endcall %}
+ {% call list_entry() %}
+ If patterns differ in domain length, one with longer domain is
+ preferred. Initial wildcard segment does not account for domain length.
+ {% endcall %}
+ {% endcall %}
+
+ {% call paragraph() %}
+ As an example, consider the URL
+ <code>http://settings.query.example.com/google/tries/destroy/adblockers//</code>.
+ Patterns matching it are, in the following order
+ {% endcall %}
+
+ {% call verbatim() %}
+http://settings.query.example.com/google/tries/destroy/adblockers/
+http://settings.query.example.com/google/tries/destroy/adblockers
+http://settings.query.example.com/google/tries/destroy/adblockers/***/
+http://settings.query.example.com/google/tries/destroy/adblockers/***
+http://settings.query.example.com/google/tries/destroy/*/
+http://settings.query.example.com/google/tries/destroy/*
+http://settings.query.example.com/google/tries/destroy/***/
+http://settings.query.example.com/google/tries/destroy/***
+http://settings.query.example.com/google/tries/**/
+http://settings.query.example.com/google/tries/**
+http://settings.query.example.com/google/tries/***/
+http://settings.query.example.com/google/tries/***
+http://settings.query.example.com/google/**/
+http://settings.query.example.com/google/**
+http://settings.query.example.com/google/***/
+http://settings.query.example.com/google/***
+http://settings.query.example.com/**/
+http://settings.query.example.com/**
+http://settings.query.example.com/***/
+http://settings.query.example.com/***
+http://***.settings.query.example.com/google/tries/destroy/adblockers/
+http://***.settings.query.example.com/google/tries/destroy/adblockers
+http://***.settings.query.example.com/google/tries/destroy/adblockers/***/
+http://***.settings.query.example.com/google/tries/destroy/adblockers/***
+http://***.settings.query.example.com/google/tries/destroy/*/
+http://***.settings.query.example.com/google/tries/destroy/*
+http://***.settings.query.example.com/google/tries/destroy/***/
+http://***.settings.query.example.com/google/tries/destroy/***
+http://***.settings.query.example.com/google/tries/**/
+http://***.settings.query.example.com/google/tries/**
+http://***.settings.query.example.com/google/tries/***/
+http://***.settings.query.example.com/google/tries/***
+http://***.settings.query.example.com/google/**/
+http://***.settings.query.example.com/google/**
+http://***.settings.query.example.com/google/***/
+http://***.settings.query.example.com/google/***
+http://***.settings.query.example.com/**/
+http://***.settings.query.example.com/**
+http://***.settings.query.example.com/***/
+http://***.settings.query.example.com/***
+http://*.query.example.com/google/tries/destroy/adblockers/
+http://*.query.example.com/google/tries/destroy/adblockers
+http://*.query.example.com/google/tries/destroy/adblockers/***/
+http://*.query.example.com/google/tries/destroy/adblockers/***
+http://*.query.example.com/google/tries/destroy/*/
+http://*.query.example.com/google/tries/destroy/*
+http://*.query.example.com/google/tries/destroy/***/
+http://*.query.example.com/google/tries/destroy/***
+http://*.query.example.com/google/tries/**/
+http://*.query.example.com/google/tries/**
+http://*.query.example.com/google/tries/***/
+http://*.query.example.com/google/tries/***
+http://*.query.example.com/google/**/
+http://*.query.example.com/google/**
+http://*.query.example.com/google/***/
+http://*.query.example.com/google/***
+http://*.query.example.com/**/
+http://*.query.example.com/**
+http://*.query.example.com/***/
+http://*.query.example.com/***
+http://***.query.example.com/google/tries/destroy/adblockers/
+http://***.query.example.com/google/tries/destroy/adblockers
+http://***.query.example.com/google/tries/destroy/adblockers/***/
+http://***.query.example.com/google/tries/destroy/adblockers/***
+http://***.query.example.com/google/tries/destroy/*/
+http://***.query.example.com/google/tries/destroy/*
+http://***.query.example.com/google/tries/destroy/***/
+http://***.query.example.com/google/tries/destroy/***
+http://***.query.example.com/google/tries/**/
+http://***.query.example.com/google/tries/**
+http://***.query.example.com/google/tries/***/
+http://***.query.example.com/google/tries/***
+http://***.query.example.com/google/**/
+http://***.query.example.com/google/**
+http://***.query.example.com/google/***/
+http://***.query.example.com/google/***
+http://***.query.example.com/**/
+http://***.query.example.com/**
+http://***.query.example.com/***/
+http://***.query.example.com/***
+http://**.example.com/google/tries/destroy/adblockers/
+http://**.example.com/google/tries/destroy/adblockers
+http://**.example.com/google/tries/destroy/adblockers/***/
+http://**.example.com/google/tries/destroy/adblockers/***
+http://**.example.com/google/tries/destroy/*/
+http://**.example.com/google/tries/destroy/*
+http://**.example.com/google/tries/destroy/***/
+http://**.example.com/google/tries/destroy/***
+http://**.example.com/google/tries/**/
+http://**.example.com/google/tries/**
+http://**.example.com/google/tries/***/
+http://**.example.com/google/tries/***
+http://**.example.com/google/**/
+http://**.example.com/google/**
+http://**.example.com/google/***/
+http://**.example.com/google/***
+http://**.example.com/**/
+http://**.example.com/**
+http://**.example.com/***/
+http://**.example.com/***
+http://***.example.com/google/tries/destroy/adblockers/
+http://***.example.com/google/tries/destroy/adblockers
+http://***.example.com/google/tries/destroy/adblockers/***/
+http://***.example.com/google/tries/destroy/adblockers/***
+http://***.example.com/google/tries/destroy/*/
+http://***.example.com/google/tries/destroy/*
+http://***.example.com/google/tries/destroy/***/
+http://***.example.com/google/tries/destroy/***
+http://***.example.com/google/tries/**/
+http://***.example.com/google/tries/**
+http://***.example.com/google/tries/***/
+http://***.example.com/google/tries/***
+http://***.example.com/google/**/
+http://***.example.com/google/**
+http://***.example.com/google/***/
+http://***.example.com/google/***
+http://***.example.com/**/
+http://***.example.com/**
+http://***.example.com/***/
+http://***.example.com/***
+ {% endcall %}
+
+ {% call paragraph() %}
+ Variants of those patterns starting with <code>http*://</code> would of
+ course match as well. They have been omitted for simplicity.
+ {% endcall %}
+
+ {% call paragraph() %}
+ For a simpler URL like <code>https://example.com</code> the patterns would
+ be
+ {% endcall %}
+
+ {% call verbatim() %}
+https://example.com
+https://example.com/***
+https://***.example.com
+https://***.example.com/***
+ {% endcall %}
+
+ {% call paragraph() %}
+ Variants of those patterns with a trailing dash added
+ would <span class="bold">not</span> match the URL. Also, the pattern
+ variants starting with <code>http*://</code> have been once again omitted.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ small_heading('Limits') }}
+
+ {% call paragraph() %}
+ In order to prevent some easy-to-conduct DoS attacks, older versions of
+ Haketilo and Hydrilla limited the lengths of domain and path parts of
+ processed URLs. This is no longer the case.
+ {% endcall %}
+ {% endcall %}
+
+ {% call section() %}
+ {{ medium_heading('Alternative solution idea: mimicking web server mechanics') }}
+
+ {% call paragraph() %}
+ While wildcard patterns as presented give a lot of flexibility, they are
+ not the only viable approach to specifying what URLs to apply
+ rules/payloads to. In fact, wildcards are different from how the server
+ side of a typical website decides what to return for a given URL request.
+ {% endcall %}
+
+ {% call paragraph() %}
+ In a typical scenario, an HTTP server like Apache reads configuration
+ files provided by its administrator and uses various (virtual host,
+ redirect, request rewrite, CGI, etc.) instructions to decide how to handle
+ given URL. Perhps using a scheme that mimics the configuration options
+ typically used with web servers would give more efficiency in specifying
+ what page settings to apply when.
+ {% endcall %}
+
+ {% call paragraph() %}
+ This approach may be considered in the future.
+ {% endcall %}
+ {% endcall %}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/simple_dependency_satisfying.py b/src/hydrilla/proxy/simple_dependency_satisfying.py
new file mode 100644
index 0000000..ba40a20
--- /dev/null
+++ b/src/hydrilla/proxy/simple_dependency_satisfying.py
@@ -0,0 +1,343 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy payloads dependency resolution.
+#
+# 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 contains logic to construct the dependency graph of Haketilo
+packages and to perform dependency resolution.
+
+The approach taken here is a very simplified one. Hopefully, this will at some
+point be replaced by a solution based on some SAT solver.
+"""
+
+import dataclasses as dc
+import typing as t
+import functools as ft
+
+from immutables import Map
+
+from ..exceptions import HaketiloException
+from .. import item_infos
+from .. import url_patterns
+
+
+@dc.dataclass(frozen=True)
+class ImpossibleSituation(HaketiloException):
+ bad_mapping_identifiers: frozenset[str]
+
+
+@dc.dataclass(frozen=True)
+class MappingRequirement:
+ identifier: str
+
+ def is_fulfilled_by(self, info: item_infos.MappingInfo) -> bool:
+ return True
+
+@dc.dataclass(frozen=True)
+class MappingRepoRequirement(MappingRequirement):
+ repo: str
+
+ def is_fulfilled_by(self, info: item_infos.MappingInfo) -> bool:
+ return info.repo == self.repo
+
+@dc.dataclass(frozen=True)
+class MappingVersionRequirement(MappingRequirement):
+ version_info: item_infos.MappingInfo
+
+ def __post_init__(self):
+ assert self.version_info.identifier == self.identifier
+
+ def is_fulfilled_by(self, info: item_infos.MappingInfo) -> bool:
+ return info == self.version_info
+
+
+@dc.dataclass(frozen=True)
+class ResourceVersionRequirement:
+ mapping_identifier: str
+ version_info: item_infos.ResourceInfo
+
+ def is_fulfilled_by(self, info: item_infos.ResourceInfo) -> bool:
+ return info == self.version_info
+
+
+@dc.dataclass
+class ComputedPayload:
+ mapping_identifier: str
+
+ resources: list[item_infos.ResourceInfo] = dc.field(default_factory=list)
+
+ allows_eval: bool = False
+ allows_cors_bypass: bool = False
+
+@dc.dataclass
+class MappingChoice:
+ info: item_infos.MappingInfo
+ required: bool = False
+ mapping_dependencies: t.Sequence[item_infos.MappingInfo] = ()
+
+ payloads: dict[str, ComputedPayload] = dc.field(default_factory=dict)
+
+
+MappingsGraph = t.Union[
+ t.Mapping[str, set[str]],
+ t.Mapping[str, frozenset[str]]
+]
+
+def _mark_mappings(
+ identifier: str,
+ mappings_graph: MappingsGraph,
+ marked_mappings: set[str]
+) -> None:
+ if identifier in marked_mappings:
+ return
+
+ marked_mappings.add(identifier)
+
+ for next_mapping in mappings_graph.get(identifier, ()):
+ _mark_mappings(next_mapping, mappings_graph, marked_mappings)
+
+
+ComputedChoices = dict[str, MappingChoice]
+
+def _compute_inter_mapping_deps(choices: ComputedChoices) \
+ -> dict[str, frozenset[str]]:
+ mapping_deps: dict[str, frozenset[str]] = {}
+
+ for mapping_choice in choices.values():
+ specs_to_resolve = [*mapping_choice.info.required_mappings]
+
+ for computed_payload in mapping_choice.payloads.values():
+ for resource_info in computed_payload.resources:
+ specs_to_resolve.extend(resource_info.required_mappings)
+
+ depended = frozenset(spec.identifier for spec in specs_to_resolve)
+ mapping_deps[mapping_choice.info.identifier] = depended
+
+ return mapping_deps
+
+@dc.dataclass(frozen=True)
+class _ComputationData:
+ resources_map: item_infos.MultirepoResourceInfoMap
+ mappings_map: item_infos.MultirepoMappingInfoMap
+
+ mappings_to_reqs: t.Mapping[str, t.Sequence[MappingRequirement]]
+
+ mappings_resources_to_reqs: t.Mapping[
+ tuple[str, str],
+ t.Sequence[ResourceVersionRequirement]
+ ]
+
+ def _satisfy_payload_resource_rec(
+ self,
+ resource_identifier: str,
+ processed_resources: set[str],
+ computed_payload: ComputedPayload
+ ) -> t.Optional[ComputedPayload]:
+ if resource_identifier in processed_resources:
+ # We forbid circular dependencies.
+ return None
+
+ multirepo_info = self.resources_map.get(resource_identifier)
+ if multirepo_info is None:
+ return None
+
+ key = (computed_payload.mapping_identifier, resource_identifier)
+ resource_reqs = self.mappings_resources_to_reqs.get(key)
+
+ if resource_reqs is None:
+ info = multirepo_info.default_info
+ else:
+ found = False
+ # From newest to oldest version.
+ for info in multirepo_info.get_all(reverse_versions=True):
+ if all(req.is_fulfilled_by(info) for req in resource_reqs):
+ found = True
+ break
+
+ if not found:
+ return None
+
+ if info in computed_payload.resources:
+ return computed_payload
+
+ processed_resources.add(resource_identifier)
+
+ if info.allows_eval:
+ computed_payload.allows_eval = True
+
+ if info.allows_cors_bypass:
+ computed_payload.allows_cors_bypass = True
+
+ for dependency_spec in info.dependencies:
+ if self._satisfy_payload_resource_rec(
+ dependency_spec.identifier,
+ processed_resources,
+ computed_payload
+ ) is None:
+ return None
+
+ processed_resources.remove(resource_identifier)
+
+ computed_payload.resources.append(info)
+
+ return computed_payload
+
+ def _satisfy_payload_resource(
+ self,
+ mapping_identifier: str,
+ resource_identifier: str
+ ) -> t.Optional[ComputedPayload]:
+ return self._satisfy_payload_resource_rec(
+ resource_identifier,
+ set(),
+ ComputedPayload(mapping_identifier)
+ )
+
+ def _compute_best_choices(self) -> ComputedChoices:
+ choices = ComputedChoices()
+
+ for multirepo_info in self.mappings_map.values():
+ choice: t.Optional[MappingChoice] = None
+
+ reqs = self.mappings_to_reqs.get(multirepo_info.identifier)
+ if reqs is None:
+ choice = MappingChoice(multirepo_info.default_info)
+ else:
+ # From newest to oldest version.
+ for info in multirepo_info.get_all(reverse_versions=True):
+ if all(req.is_fulfilled_by(info) for req in reqs):
+ choice = MappingChoice(info=info, required=True)
+ break
+
+ if choice is None:
+ continue
+
+ failure = False
+
+ processed_patterns = set()
+
+ for pattern, resource_spec in choice.info.payloads.items():
+ if pattern.orig_url in processed_patterns:
+ continue
+ processed_patterns.add(pattern.orig_url)
+
+ computed_payload = self._satisfy_payload_resource(
+ mapping_identifier = choice.info.identifier,
+ resource_identifier = resource_spec.identifier
+ )
+ if computed_payload is None:
+ failure = True
+ break
+
+ if choice.info.allows_eval:
+ computed_payload.allows_eval = True
+
+ if choice.info.allows_cors_bypass:
+ computed_payload.allows_cors_bypass = True
+
+ choice.payloads[pattern.orig_url] = computed_payload
+
+ if not failure:
+ choices[choice.info.identifier] = choice
+
+ return choices
+
+ def compute_payloads(self) -> ComputedChoices:
+ choices = self._compute_best_choices()
+
+ mapping_deps = _compute_inter_mapping_deps(choices)
+
+ reverse_deps: dict[str, set[str]] = {}
+
+ for depending, depended_set in mapping_deps.items():
+ for depended in depended_set:
+ reverse_deps.setdefault(depended, set()).add(depending)
+
+ bad_mappings: set[str] = set()
+
+ for depended_identifier in reverse_deps.keys():
+ if depended_identifier not in choices:
+ _mark_mappings(depended_identifier, reverse_deps, bad_mappings)
+
+ bad_required_mappings: list[str] = []
+
+ for identifier in self.mappings_to_reqs.keys():
+ if identifier in bad_mappings or identifier not in choices:
+ bad_required_mappings.append(identifier)
+
+ if len(bad_required_mappings) > 0:
+ raise ImpossibleSituation(frozenset(bad_required_mappings))
+
+ for identifier in bad_mappings:
+ choices.pop(identifier, None)
+
+ required_mappings: set[str] = set()
+
+ for identifier in self.mappings_to_reqs.keys():
+ _mark_mappings(identifier, mapping_deps, required_mappings)
+
+ for identifier in required_mappings:
+ choices[identifier].required = True
+
+ for mapping_choice in choices.values():
+ depended_set = mapping_deps[mapping_choice.info.identifier]
+ mapping_choice.mapping_dependencies = \
+ tuple(choices[identifier].info for identifier in depended_set)
+
+ return choices
+
+def compute_payloads(
+ resources: t.Iterable[item_infos.ResourceInfo],
+ mappings: t.Iterable[item_infos.MappingInfo],
+ mapping_requirements: t.Iterable[MappingRequirement],
+ resource_requirements: t.Iterable[ResourceVersionRequirement]
+) -> ComputedChoices:
+ resources_map: item_infos.MultirepoResourceInfoMap = \
+ ft.reduce(item_infos.register_in_multirepo_map, resources, Map())
+ mappings_map: item_infos.MultirepoMappingInfoMap = \
+ ft.reduce(item_infos.register_in_multirepo_map, mappings, Map())
+
+ mappings_to_reqs: dict[str, list[MappingRequirement]] = {}
+ for mapping_req in mapping_requirements:
+ mappings_to_reqs.setdefault(mapping_req.identifier, [])\
+ .append(mapping_req)
+
+ mappings_resources_to_reqs: dict[
+ tuple[str, str],
+ list[ResourceVersionRequirement]
+ ] = {}
+ for resource_req in resource_requirements:
+ info = resource_req.version_info
+ key = (resource_req.mapping_identifier, info.identifier)
+ mappings_resources_to_reqs.setdefault(key, [])\
+ .append(resource_req)
+
+ return _ComputationData(
+ mappings_map = mappings_map,
+ resources_map = resources_map,
+ mappings_to_reqs = mappings_to_reqs,
+ mappings_resources_to_reqs = mappings_resources_to_reqs
+ ).compute_payloads()
diff --git a/src/hydrilla/proxy/state.py b/src/hydrilla/proxy/state.py
new file mode 100644
index 0000000..f73d01f
--- /dev/null
+++ b/src/hydrilla/proxy/state.py
@@ -0,0 +1,658 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (interface definition through abstract
+# class).
+#
+# 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 defines API for keeping track of all settings, rules, mappings and
+resources.
+"""
+
+import dataclasses as dc
+import typing as t
+
+from pathlib import Path
+from abc import ABC, abstractmethod
+from enum import Enum
+from datetime import datetime
+
+from immutables import Map
+
+from ..exceptions import HaketiloException
+from ..versions import VerTuple
+from ..url_patterns import ParsedPattern
+from .. import item_infos
+from .simple_dependency_satisfying import ImpossibleSituation
+
+
+class EnabledStatus(Enum):
+ """
+ ENABLED - User wished to always apply given mapping when it matches site's
+ URL.
+
+ DISABLED - User wished to never apply given mapping.
+
+ NO_MARK - User has not configured given mapping.
+ """
+ ENABLED = 'E'
+ DISABLED = 'D'
+ NO_MARK = 'N'
+
+
+class FrozenStatus(Enum):
+ """
+ EXACT_VERSION - User wished to always use the same version of a mapping.
+
+ REPOSITORY - User wished to always use a version of the mapping from the
+ same repository.
+
+ NOT_FROZEN - User did not restrict updates of the mapping.
+ """
+ EXACT_VERSION = 'E'
+ REPOSITORY = 'R'
+ NOT_FROZEN = 'N'
+
+ @staticmethod
+ def make(letter: t.Optional[str]) -> t.Optional['FrozenStatus']:
+ if letter is None:
+ return None
+
+ return FrozenStatus(letter)
+
+
+class InstalledStatus(Enum):
+ """
+ INSTALLED - Mapping's all files are present and mapping data is not going to
+ be automatically removed.
+
+ NOT_INSTALLED - Some of the mapping's files might be absent. Mapping can be
+ automatically removed if it is orphaned.
+
+ FAILED_TO_INSTALL - Same as "NOT_INSTALLED" but we additionally know that
+ the last automatic attempt to install mapping's files from repository
+ was unsuccessful.
+ """
+ INSTALLED = 'I'
+ NOT_INSTALLED = 'N'
+ FAILED_TO_INSTALL = 'F'
+
+
+class ActiveStatus(Enum):
+ """
+ REQUIRED - Mapping version got active to fulfill a requirement of some (this
+ or another) explicitly enabled mapping.
+
+ AUTO - Mapping version was activated automatically.
+
+ NOT_ACTIVE - Mapping version is not currently being used.
+ """
+ REQUIRED = 'R'
+ AUTO = 'A'
+ NOT_ACTIVE = 'N'
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class Ref:
+ """...."""
+ id: str
+
+ def __post_init__(self):
+ assert isinstance(self.id, str)
+
+
+RefType = t.TypeVar('RefType', bound=Ref)
+
+class Store(ABC, t.Generic[RefType]):
+ @abstractmethod
+ def get(self, id) -> RefType:
+ ...
+
+
+class RulePatternInvalid(HaketiloException):
+ pass
+
+@dc.dataclass(frozen=True)
+class RuleDisplayInfo:
+ ref: 'RuleRef'
+ pattern: str
+ allow_scripts: bool
+
+# mypy needs to be corrected:
+# https://stackoverflow.com/questions/70999513/conflict-between-mix-ins-for-abstract-dataclasses/70999704#70999704
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class RuleRef(Ref):
+ @abstractmethod
+ def remove(self) -> None:
+ ...
+
+ @abstractmethod
+ def update(
+ self,
+ *,
+ pattern: t.Optional[str] = None,
+ allow: t.Optional[bool] = None
+ ) -> None:
+ ...
+
+ @abstractmethod
+ def get_display_info(self) -> RuleDisplayInfo:
+ ...
+
+class RuleStore(Store[RuleRef]):
+ @abstractmethod
+ def get_display_infos(self, allow: t.Optional[bool] = None) \
+ -> t.Sequence[RuleDisplayInfo]:
+ ...
+
+ @abstractmethod
+ def add(self, pattern: str, allow: bool) -> RuleRef:
+ ...
+
+ @abstractmethod
+ def get_by_pattern(self, pattern: str) -> RuleRef:
+ ...
+
+
+class RepoNameInvalid(HaketiloException):
+ pass
+
+class RepoNameTaken(HaketiloException):
+ pass
+
+class RepoUrlInvalid(HaketiloException):
+ pass
+
+class RepoCommunicationError(HaketiloException):
+ pass
+
+@dc.dataclass(frozen=True)
+class FileInstallationError(HaketiloException):
+ repo_id: str
+ sha256: str
+
+@dc.dataclass(frozen=True)
+class FileIntegrityError(FileInstallationError):
+ invalid_sha256: str
+
+@dc.dataclass(frozen=True)
+class FileMissingError(FileInstallationError):
+ pass
+
+class RepoApiVersionUnsupported(HaketiloException):
+ pass
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class RepoRef(Ref):
+ """...."""
+ @abstractmethod
+ def remove(self) -> None:
+ """...."""
+ ...
+
+ @abstractmethod
+ def update(
+ self,
+ *,
+ name: t.Optional[str] = None,
+ url: t.Optional[str] = None
+ ) -> None:
+ """...."""
+ ...
+
+ @abstractmethod
+ def refresh(self) -> None:
+ """...."""
+ ...
+
+ @abstractmethod
+ def get_display_info(self) -> 'RepoDisplayInfo':
+ ...
+
+@dc.dataclass(frozen=True)
+class RepoDisplayInfo:
+ ref: RepoRef
+ is_local_semirepo: bool
+ name: str
+ url: str
+ deleted: bool
+ last_refreshed: t.Optional[datetime]
+ resource_count: int
+ mapping_count: int
+
+class RepoStore(Store[RepoRef]):
+ @abstractmethod
+ def get_display_infos(self, include_deleted: bool = False) -> \
+ t.Sequence[RepoDisplayInfo]:
+ ...
+
+ @abstractmethod
+ def add(self, name: str, url: str) -> RepoRef:
+ ...
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class RepoIterationRef(Ref):
+ """...."""
+ pass
+
+
+@dc.dataclass(frozen=True)
+class FileData:
+ mime_type: str
+ name: str
+ contents: bytes
+
+
+@dc.dataclass(frozen=True)
+class MappingDisplayInfo(item_infos.CorrespondsToMappingDCMixin):
+ ref: 'MappingRef'
+ identifier: str
+ enabled: EnabledStatus
+ frozen: t.Optional[FrozenStatus]
+ active_version: t.Optional['MappingVersionDisplayInfo']
+
+@dc.dataclass(frozen=True)
+class RichMappingDisplayInfo(MappingDisplayInfo):
+ all_versions: t.Sequence['MappingVersionDisplayInfo']
+
+@dc.dataclass(frozen=True)
+class MappingVersionDisplayInfo(item_infos.CorrespondsToMappingDCMixin):
+ ref: 'MappingVersionRef'
+ info: item_infos.MappingInfo
+ installed: InstalledStatus
+ active: ActiveStatus
+ is_orphan: bool
+ is_local: bool
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class MappingRef(Ref, item_infos.CorrespondsToMappingDCMixin):
+ """...."""
+ @abstractmethod
+ def update_status(
+ self,
+ enabled: EnabledStatus,
+ frozen: t.Optional[FrozenStatus] = None
+ ) -> None:
+ ...
+
+ @abstractmethod
+ def get_display_info(self) -> RichMappingDisplayInfo:
+ ...
+
+
+class MappingStore(Store[MappingRef]):
+ @abstractmethod
+ def get_display_infos(self) -> t.Sequence[MappingDisplayInfo]:
+ ...
+
+ @abstractmethod
+ def get_by_identifier(self, identifier: str) -> MappingRef:
+ ...
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class MappingVersionRef(Ref, item_infos.CorrespondsToMappingDCMixin):
+ @abstractmethod
+ def install(self) -> None:
+ ...
+
+ @abstractmethod
+ def uninstall(self) -> t.Optional['MappingVersionRef']:
+ ...
+
+ @abstractmethod
+ def ensure_depended_items_installed(self) -> None:
+ ...
+
+ @abstractmethod
+ def update_mapping_status(
+ self,
+ enabled: EnabledStatus,
+ frozen: t.Optional[FrozenStatus] = None
+ ) -> None:
+ ...
+
+ @abstractmethod
+ def get_license_file(self, name: str) -> FileData:
+ ...
+
+ @abstractmethod
+ def get_upstream_license_file_url(self, name: str) -> str:
+ ...
+
+ @abstractmethod
+ def get_required_mapping(self, identifier: str) -> 'MappingVersionRef':
+ ...
+
+ @abstractmethod
+ def get_payload_resource(self, pattern: str, identifier: str) \
+ -> 'ResourceVersionRef':
+ ...
+
+ @abstractmethod
+ def get_item_display_info(self) -> RichMappingDisplayInfo:
+ ...
+
+class MappingVersionStore(Store[MappingVersionRef]):
+ pass
+
+
+@dc.dataclass(frozen=True)
+class ResourceDisplayInfo(item_infos.CorrespondsToResourceDCMixin):
+ ref: 'ResourceRef'
+ identifier: str
+
+@dc.dataclass(frozen=True)
+class RichResourceDisplayInfo(ResourceDisplayInfo):
+ all_versions: t.Sequence['ResourceVersionDisplayInfo']
+
+@dc.dataclass(frozen=True)
+class ResourceVersionDisplayInfo(item_infos.CorrespondsToResourceDCMixin):
+ ref: 'ResourceVersionRef'
+ info: item_infos.ResourceInfo
+ installed: InstalledStatus
+ active: ActiveStatus
+ is_orphan: bool
+ is_local: bool
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class ResourceRef(Ref, item_infos.CorrespondsToResourceDCMixin):
+ @abstractmethod
+ def get_display_info(self) -> RichResourceDisplayInfo:
+ ...
+
+class ResourceStore(Store[ResourceRef]):
+ @abstractmethod
+ def get_display_infos(self) -> t.Sequence[ResourceDisplayInfo]:
+ ...
+
+ @abstractmethod
+ def get_by_identifier(self, identifier: str) -> ResourceRef:
+ ...
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class ResourceVersionRef(Ref, item_infos.CorrespondsToResourceDCMixin):
+ @abstractmethod
+ def install(self) -> None:
+ ...
+
+ @abstractmethod
+ def uninstall(self) -> t.Optional['ResourceVersionRef']:
+ ...
+
+ @abstractmethod
+ def get_license_file(self, name: str) -> FileData:
+ ...
+
+ @abstractmethod
+ def get_resource_file(self, name: str) -> FileData:
+ ...
+
+ @abstractmethod
+ def get_upstream_license_file_url(self, name: str) -> str:
+ ...
+
+ @abstractmethod
+ def get_upstream_resource_file_url(self, name: str) -> str:
+ ...
+
+ @abstractmethod
+ def get_dependency(self, identifier: str) -> 'ResourceVersionRef':
+ ...
+
+ @abstractmethod
+ def get_item_display_info(self) -> RichResourceDisplayInfo:
+ ...
+
+class ResourceVersionStore(Store[ResourceVersionRef]):
+ pass
+
+
+@dc.dataclass(frozen=True)
+class PayloadKey:
+ """...."""
+ ref: 'PayloadRef'
+
+ mapping_identifier: str
+
+ def __lt__(self, other: 'PayloadKey') -> bool:
+ """...."""
+ return self.mapping_identifier < other.mapping_identifier
+
+@dc.dataclass(frozen=True)
+class PayloadData:
+ """...."""
+ ref: 'PayloadRef'
+
+ explicitly_enabled: bool
+ unique_token: str
+ mapping_identifier: str
+ pattern: str
+ pattern_path_segments: tuple[str, ...]
+ eval_allowed: bool
+ cors_bypass_allowed: bool
+ global_secret: bytes
+
+@dc.dataclass(frozen=True)
+class PayloadDisplayInfo:
+ ref: 'PayloadRef'
+
+ mapping_info: MappingVersionDisplayInfo
+ pattern: str
+ has_problems: bool
+
+@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
+class PayloadRef(Ref):
+ """...."""
+ @abstractmethod
+ def get_data(self) -> PayloadData:
+ """...."""
+ ...
+
+ @abstractmethod
+ def has_problems(self) -> bool:
+ ...
+
+ @abstractmethod
+ def get_display_info(self) -> PayloadDisplayInfo:
+ ...
+
+ @abstractmethod
+ def ensure_items_installed(self) -> None:
+ """...."""
+ ...
+
+ @abstractmethod
+ def get_script_paths(self) \
+ -> t.Iterable[t.Sequence[str]]:
+ """...."""
+ ...
+
+ @abstractmethod
+ def get_file_data(self, path: t.Sequence[str]) \
+ -> t.Optional[FileData]:
+ """...."""
+ ...
+
+class PayloadStore(Store[PayloadRef]):
+ pass
+
+
+class MappingUseMode(Enum):
+ """
+ AUTO - Apply mappings except for those explicitly disabled.
+
+ WHEN_ENABLED - Only apply mappings explicitly marked as enabled. Don't apply
+ unmarked nor explicitly disabled mappings.
+
+ QUESTION - Automatically apply mappings that are explicitly enabled. Ask
+ whether to enable unmarked mappings. Don't apply explicitly disabled
+ ones.
+ """
+ AUTO = 'A'
+ WHEN_ENABLED = 'W'
+ QUESTION = 'Q'
+
+
+class PopupStyle(Enum):
+ """
+ DIALOG - Make popup open inside an iframe on the current page.
+
+ TAB - Make popup open in a new tab.
+ """
+ DIALOG = 'D'
+ TAB = 'T'
+
+@dc.dataclass(frozen=True)
+class PopupSettings:
+ # We'll implement button later.
+ #button_trigger: bool
+ keyboard_trigger: bool
+ style: PopupStyle
+
+ @property
+ def popup_enabled(self) -> bool:
+ return self.keyboard_trigger #or self.button_trigger
+
+@dc.dataclass(frozen=True)
+class HaketiloGlobalSettings:
+ """...."""
+ mapping_use_mode: MappingUseMode
+ default_allow_scripts: bool
+ advanced_user: bool
+ repo_refresh_seconds: int
+ locale: t.Optional[str]
+ update_waiting: bool
+
+ default_popup_jsallowed: PopupSettings
+ default_popup_jsblocked: PopupSettings
+ default_popup_payloadon: PopupSettings
+
+
+class Logger(ABC):
+ @abstractmethod
+ def warn(self, msg: str) -> None:
+ ...
+
+
+class MissingItemError(ValueError):
+ """...."""
+ pass
+
+
+@dc.dataclass(frozen=True)
+class OrphanItemsStats:
+ mappings: int
+ resources: int
+
+
+class HaketiloState(ABC):
+ """...."""
+ @abstractmethod
+ def import_items(self, malcontent_path: Path) -> None:
+ ...
+
+ @abstractmethod
+ def count_orphan_items(self) -> OrphanItemsStats:
+ ...
+
+ @abstractmethod
+ def prune_orphan_items(self) -> None:
+ ...
+
+ @abstractmethod
+ def rule_store(self) -> RuleStore:
+ ...
+
+ @abstractmethod
+ def repo_store(self) -> RepoStore:
+ """...."""
+ ...
+
+ @abstractmethod
+ def mapping_store(self) -> MappingStore:
+ ...
+
+ @abstractmethod
+ def mapping_version_store(self) -> MappingVersionStore:
+ ...
+
+ @abstractmethod
+ def resource_store(self) -> ResourceStore:
+ ...
+
+ @abstractmethod
+ def resource_version_store(self) -> ResourceVersionStore:
+ ...
+
+ @abstractmethod
+ def payload_store(self) -> PayloadStore:
+ ...
+
+ @abstractmethod
+ def get_secret(self) -> bytes:
+ ...
+
+ @abstractmethod
+ def get_settings(self) -> HaketiloGlobalSettings:
+ """...."""
+ ...
+
+ @abstractmethod
+ def update_settings(
+ self,
+ *,
+ mapping_use_mode: t.Optional[MappingUseMode] = None,
+ default_allow_scripts: t.Optional[bool] = None,
+ advanced_user: t.Optional[bool] = None,
+ repo_refresh_seconds: t.Optional[int] = None,
+ locale: t.Optional[str] = None,
+ default_popup_settings: t.Mapping[str, PopupSettings] = {}
+ ) -> None:
+ ...
+
+ @abstractmethod
+ def upate_all_items(self) -> None:
+ ...
+
+ @property
+ @abstractmethod
+ def listen_host(self) -> str:
+ ...
+
+ @property
+ @abstractmethod
+ def listen_port(self) -> int:
+ ...
+
+ @abstractmethod
+ def launch_browser(self) -> bool:
+ ...
+
+ @property
+ @abstractmethod
+ def logger(self) -> Logger:
+ ...
diff --git a/src/hydrilla/proxy/state_impl/__init__.py b/src/hydrilla/proxy/state_impl/__init__.py
new file mode 100644
index 0000000..5398cdd
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/__init__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+from .concrete_state import ConcreteHaketiloState
diff --git a/src/hydrilla/proxy/state_impl/_operations/__init__.py b/src/hydrilla/proxy/state_impl/_operations/__init__.py
new file mode 100644
index 0000000..359e2f5
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/_operations/__init__.py
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+from .prune_orphans import prune_orphans
+from .pull_missing_files import pull_missing_files
+from .load_packages import _load_packages_no_state_update
+from .recompute_dependencies import _recompute_dependencies_no_state_update
diff --git a/src/hydrilla/proxy/state_impl/_operations/load_packages.py b/src/hydrilla/proxy/state_impl/_operations/load_packages.py
new file mode 100644
index 0000000..288ee5b
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/_operations/load_packages.py
@@ -0,0 +1,410 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (import of packages from disk files).
+#
+# 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.
+
+"""
+....
+"""
+
+import io
+import mimetypes
+import sqlite3
+import hashlib
+import dataclasses as dc
+import typing as t
+
+from pathlib import Path, PurePosixPath
+
+from .... import versions
+from .... import item_infos
+from ... import state
+from .recompute_dependencies import _recompute_dependencies_no_state_update, \
+ FileResolver
+from .prune_orphans import prune_orphans
+
+def make_repo_iteration(cursor: sqlite3.Cursor, repo_id: int) -> int:
+ cursor.execute(
+ '''
+ SELECT
+ next_iteration
+ FROM
+ repos
+ WHERE
+ repo_id = ?;
+ ''',
+ (repo_id,)
+ )
+
+ (next_iteration,), = cursor.fetchall()
+
+ cursor.execute(
+ '''
+ INSERT INTO repo_iterations(repo_id, iteration)
+ VALUES(?, ?);
+ ''',
+ (repo_id, next_iteration)
+ )
+
+ cursor.execute(
+ '''
+ SELECT
+ repo_iteration_id
+ FROM
+ repo_iterations
+ WHERE
+ repo_id = ? AND iteration = ?;
+ ''',
+ (repo_id, next_iteration)
+ )
+
+ (repo_iteration_id,), = cursor.fetchall()
+
+ cursor.execute(
+ '''
+ UPDATE
+ repos
+ SET
+ next_iteration = ?,
+ active_iteration_id = (
+ CASE
+ WHEN repo_id = 1 THEN NULL
+ ELSE ?
+ END
+ ),
+ last_refreshed = (
+ CASE
+ WHEN repo_id = 1 THEN NULL
+ ELSE STRFTIME('%s', 'NOW')
+ END
+ )
+ WHERE
+ repo_id = ?;
+ ''',
+ (next_iteration + 1, repo_iteration_id, repo_id)
+ )
+
+ return repo_iteration_id
+
+def get_or_make_item(cursor: sqlite3.Cursor, type: str, identifier: str) -> int:
+ type_letter = {'resource': 'R', 'mapping': 'M'}[type]
+
+ cursor.execute(
+ '''
+ INSERT OR IGNORE INTO items(type, identifier)
+ VALUES(?, ?);
+ ''',
+ (type_letter, identifier)
+ )
+
+ cursor.execute(
+ '''
+ SELECT
+ item_id
+ FROM
+ items
+ WHERE
+ type = ? AND identifier = ?;
+ ''',
+ (type_letter, identifier)
+ )
+
+ (item_id,), = cursor.fetchall()
+
+ return item_id
+
+def update_or_make_item_version(
+ cursor: sqlite3.Cursor,
+ item_id: int,
+ version: versions.VerTuple,
+ installed: str,
+ repo_iteration_id: int,
+ repo_id: int,
+ definition: bytes
+) -> int:
+ ver_str = versions.version_string(version)
+
+ definition_sha256 = hashlib.sha256(definition).digest().hex()
+
+ cursor.execute(
+ '''
+ SELECT
+ item_version_id
+ FROM
+ item_versions AS iv
+ JOIN repo_iterations AS ri USING (repo_iteration_id)
+ JOIN repos AS r USING (repo_id)
+ WHERE
+ r.repo_id = ? AND iv.definition_sha256 = ?;
+ ''',
+ (repo_id, definition_sha256)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows != []:
+ (item_version_id,), = rows
+ cursor.execute(
+ '''
+ UPDATE
+ item_versions
+ SET
+ installed = (
+ CASE
+ WHEN installed = 'I' OR ? = 'I' THEN 'I'
+ ELSE 'N'
+ END
+ ),
+ repo_iteration_id = ?
+ WHERE
+ item_version_id = ?;
+ ''',
+ (installed, repo_iteration_id, item_version_id)
+ )
+
+ return item_version_id
+
+ cursor.execute(
+ '''
+ INSERT INTO item_versions(
+ item_id,
+ version,
+ installed,
+ repo_iteration_id,
+ definition,
+ definition_sha256
+ )
+ VALUES(?, ?, ?, ?, ?, ?);
+ ''',
+ (item_id, ver_str, installed, repo_iteration_id, definition,
+ definition_sha256)
+ )
+
+ cursor.execute(
+ '''
+ SELECT
+ item_version_id
+ FROM
+ item_versions
+ WHERE
+ item_id = ? AND version = ? AND repo_iteration_id = ?;
+ ''',
+ (item_id, ver_str, repo_iteration_id)
+ )
+
+ (item_version_id,), = cursor.fetchall()
+
+ return item_version_id
+
+def make_mapping_status(cursor: sqlite3.Cursor, item_id: int) -> None:
+ cursor.execute(
+ 'INSERT OR IGNORE INTO mapping_statuses(item_id) VALUES(?);',
+ (item_id,)
+ )
+
+def get_or_make_file(cursor: sqlite3.Cursor, sha256: str) -> int:
+ cursor.execute('INSERT OR IGNORE INTO files(sha256) VALUES(?);', (sha256,))
+
+ cursor.execute('SELECT file_id FROM files WHERE sha256 = ?;', (sha256,))
+
+ (file_id,), = cursor.fetchall()
+
+ return file_id
+
+def make_file_use(
+ cursor: sqlite3.Cursor,
+ item_version_id: int,
+ file_id: int,
+ name: str,
+ type: str,
+ mime_type: str,
+ idx: int
+) -> None:
+ cursor.execute(
+ '''
+ INSERT OR IGNORE INTO file_uses(
+ item_version_id,
+ file_id,
+ name,
+ type,
+ mime_type,
+ idx
+ )
+ VALUES(?, ?, ?, ?, ?, ?);
+ ''',
+ (item_version_id, file_id, name, type, mime_type, idx)
+ )
+
+@dc.dataclass(frozen=True)
+class _FileInfo:
+ id: int
+ extension: str
+
+def _add_item(
+ cursor: sqlite3.Cursor,
+ info: item_infos.AnyInfo,
+ definition: bytes,
+ repo_iteration_id: int,
+ repo_id: int
+) -> None:
+ item_id = get_or_make_item(cursor, info.type.value, info.identifier)
+
+ if isinstance(info, item_infos.MappingInfo):
+ make_mapping_status(cursor, item_id)
+
+ item_version_id = update_or_make_item_version(
+ cursor = cursor,
+ item_id = item_id,
+ version = info.version,
+ installed = 'I' if repo_id == 1 else 'N',
+ repo_iteration_id = repo_iteration_id,
+ repo_id = repo_id,
+ definition = definition
+ )
+
+ file_infos = {}
+
+ file_specifiers = [*info.source_copyright]
+ if isinstance(info, item_infos.ResourceInfo):
+ file_specifiers.extend(info.scripts)
+
+ for file_spec in file_specifiers:
+ file_id = get_or_make_file(cursor, file_spec.sha256)
+
+ suffix = PurePosixPath(file_spec.name).suffix
+
+ file_infos[file_spec.sha256] = _FileInfo(file_id, suffix)
+
+ for idx, file_spec in enumerate(info.source_copyright):
+ file_info = file_infos[file_spec.sha256]
+
+ mime = mimetypes.types_map.get(file_info.extension)
+ if mime is None:
+ mime = mimetypes.common_types.get(file_info.extension)
+ if mime is None:
+ mime = 'application/octet-stream'
+ if mime is None and file_info.extension == '.spdx':
+ # We don't know of any estabilished mime type for tag-value SPDX
+ # reports. Let's use the following for now.
+ mime = 'text/spdx'
+
+ make_file_use(
+ cursor,
+ item_version_id = item_version_id,
+ file_id = file_info.id,
+ name = file_spec.name,
+ type = 'L',
+ mime_type = mime,
+ idx = idx
+ )
+
+ if isinstance(info, item_infos.MappingInfo):
+ return
+
+ for idx, file_spec in enumerate(info.scripts):
+ file_info = file_infos[file_spec.sha256]
+ make_file_use(
+ cursor,
+ item_version_id = item_version_id,
+ file_id = file_info.id,
+ name = file_spec.name,
+ type = 'W',
+ mime_type = 'application/javascript',
+ idx = idx
+ )
+
+AnyInfoVar = t.TypeVar(
+ 'AnyInfoVar',
+ item_infos.ResourceInfo,
+ item_infos.MappingInfo
+)
+
+def _read_items(malcontent_path: Path, info_class: t.Type[AnyInfoVar]) \
+ -> t.Iterator[tuple[AnyInfoVar, bytes]]:
+ item_type_path = malcontent_path / info_class.type.value
+ if not item_type_path.is_dir():
+ return
+
+ for item_path in item_type_path.iterdir():
+ if not item_path.is_dir():
+ continue
+
+ for item_version_path in item_path.iterdir():
+ definition = item_version_path.read_bytes()
+ item_info = info_class.load(definition)
+
+ assert item_info.identifier == item_path.name
+ assert versions.version_string(item_info.version) == \
+ item_version_path.name
+
+ yield item_info, definition
+
+@dc.dataclass(frozen=True)
+class MalcontentFileResolver(FileResolver):
+ malcontent_dir_path: Path
+
+ def by_sha256(self, sha256: str) -> bytes:
+ file_path = self.malcontent_dir_path / 'file' / 'sha256' / sha256
+ if not file_path.is_file():
+ raise state.FileMissingError(repo_id='1', sha256=sha256)
+
+ return file_path.read_bytes()
+
+def _load_packages_no_state_update(
+ cursor: sqlite3.Cursor,
+ malcontent_path: Path,
+ repo_id: int
+) -> int:
+ assert cursor.connection.in_transaction
+
+ repo_iteration_id = make_repo_iteration(cursor, repo_id)
+
+ for type in [item_infos.ItemType.RESOURCE, item_infos.ItemType.MAPPING]:
+ info: item_infos.AnyInfo
+ for info, definition in _read_items( # type: ignore
+ malcontent_path,
+ type.info_class
+ ):
+ _add_item(
+ cursor = cursor,
+ info = info,
+ definition = definition,
+ repo_iteration_id = repo_iteration_id,
+ repo_id = repo_id
+ )
+
+ if repo_id != 1:
+ # In case of local semirepo (repo_id = 1) all packages from previous
+ # iteration are already orphans and can be assumed to be in a pruned
+ # state no matter what.
+ prune_orphans(cursor)
+
+ _recompute_dependencies_no_state_update(
+ cursor = cursor,
+ unlocked_required_mappings = [],
+ semirepo_file_resolver = MalcontentFileResolver(malcontent_path)
+ )
+
+ return repo_iteration_id
diff --git a/src/hydrilla/proxy/state_impl/_operations/prune_orphans.py b/src/hydrilla/proxy/state_impl/_operations/prune_orphans.py
new file mode 100644
index 0000000..7bb5eb5
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/_operations/prune_orphans.py
@@ -0,0 +1,182 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (removal of packages that are not used).
+#
+# 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.
+
+"""
+....
+"""
+
+import sqlite3
+
+from pathlib import Path
+
+
+def _remove_item_versions(cursor: sqlite3.Cursor, with_installed: bool) -> None:
+ cursor.execute(
+ '''
+ CREATE TEMPORARY TABLE __removed_versions(
+ item_version_id INTEGER PRIMARY KEY
+ );
+ '''
+ )
+
+ condition = "iv.active != 'R'" if with_installed else "iv.installed != 'I'"
+
+ cursor.execute(
+ f'''
+ INSERT INTO
+ __removed_versions
+ SELECT
+ iv.item_version_id
+ FROM
+ item_versions AS iv
+ JOIN orphan_iterations AS oi USING (repo_iteration_id)
+ WHERE
+ {condition};
+ '''
+ )
+
+ cursor.execute(
+ '''
+ UPDATE
+ mapping_statuses
+ SET
+ active_version_id = NULL
+ WHERE
+ active_version_id IN __removed_versions;
+ '''
+ )
+
+ cursor.execute(
+ '''
+ DELETE FROM
+ item_versions
+ WHERE
+ item_version_id IN __removed_versions;
+ '''
+ )
+
+ cursor.execute('DROP TABLE __removed_versions;')
+
+_remove_items_sql = '''
+WITH removed_items AS (
+ SELECT
+ i.item_id
+ FROM
+ items AS i
+ LEFT JOIN item_versions AS iv USING (item_id)
+ LEFT JOIN mapping_statuses AS ms USING (item_id)
+ WHERE
+ iv.item_version_id IS NULL AND
+ (i.type = 'R' OR ms.enabled = 'N')
+)
+DELETE FROM
+ items
+WHERE
+ item_id IN removed_items;
+'''
+
+_remove_files_sql = '''
+WITH removed_files AS (
+ SELECT
+ f.file_id
+ FROM
+ files AS f
+ LEFT JOIN file_uses AS fu USING (file_id)
+ WHERE
+ fu.file_use_id IS NULL
+)
+DELETE FROM
+ files
+WHERE
+ file_id IN removed_files;
+'''
+
+_forget_files_data_sql = '''
+WITH forgotten_files AS (
+ SELECT
+ f.file_id
+ FROM
+ files AS f
+ JOIN file_uses AS fu
+ USING (file_id)
+ LEFT JOIN item_versions AS iv
+ ON (fu.item_version_id = iv.item_version_id AND
+ iv.installed = 'I')
+ GROUP BY
+ f.file_id
+ HAVING
+ COUNT(iv.item_version_id) = 0
+)
+UPDATE
+ files
+SET
+ data = NULL
+WHERE
+ file_id IN forgotten_files;
+'''
+
+_remove_repo_iterations_sql = '''
+WITH removed_iterations AS (
+ SELECT
+ oi.repo_iteration_id
+ FROM
+ orphan_iterations AS oi
+ LEFT JOIN item_versions AS iv USING (repo_iteration_id)
+ WHERE
+ iv.item_version_id IS NULL
+)
+DELETE FROM
+ repo_iterations
+WHERE
+ repo_iteration_id IN removed_iterations;
+'''
+
+_remove_repos_sql = '''
+WITH removed_repos AS (
+ SELECT
+ r.repo_id
+ FROM
+ repos AS r
+ LEFT JOIN repo_iterations AS ri USING (repo_id)
+ WHERE
+ r.deleted AND ri.repo_iteration_id IS NULL AND r.repo_id != 1
+)
+DELETE FROM
+ repos
+WHERE
+ repo_id IN removed_repos;
+'''
+
+def prune_orphans(cursor: sqlite3.Cursor, aggressive: bool = False) -> None:
+ assert cursor.connection.in_transaction
+
+ _remove_item_versions(cursor, with_installed=aggressive)
+ cursor.execute(_remove_items_sql)
+ cursor.execute(_remove_files_sql)
+ cursor.execute(_forget_files_data_sql)
+ cursor.execute(_remove_repo_iterations_sql)
+ cursor.execute(_remove_repos_sql)
diff --git a/src/hydrilla/proxy/state_impl/_operations/pull_missing_files.py b/src/hydrilla/proxy/state_impl/_operations/pull_missing_files.py
new file mode 100644
index 0000000..b4bc1ac
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/_operations/pull_missing_files.py
@@ -0,0 +1,110 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (download of package files).
+#
+# 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.
+
+"""
+....
+"""
+
+import sqlite3
+import hashlib
+
+from abc import ABC, abstractmethod
+from pathlib import Path
+from urllib.parse import urljoin
+
+import requests
+
+from ... import state
+
+
+class FileResolver(ABC):
+ @abstractmethod
+ def by_sha256(self, sha256: str) -> bytes:
+ ...
+
+class DummyFileResolver(FileResolver):
+ def by_sha256(self, sha256: str) -> bytes:
+ raise NotImplementedError()
+
+def pull_missing_files(
+ cursor: sqlite3.Cursor,
+ semirepo_file_resolver: FileResolver = DummyFileResolver()
+) -> None:
+ cursor.execute(
+ '''
+ SELECT DISTINCT
+ f.file_id, f.sha256,
+ r.repo_id, r.url
+ FROM
+ repos AS R
+ JOIN repo_iterations AS ri USING (repo_id)
+ JOIN item_versions AS iv USING (repo_iteration_id)
+ JOIN file_uses AS fu USING (item_version_id)
+ JOIN files AS f USING (file_id)
+ WHERE
+ iv.installed = 'I' AND f.data IS NULL;
+ '''
+ )
+
+ rows = cursor.fetchall()
+
+ for file_id, sha256, repo_id, repo_url in rows:
+ if repo_id == 1:
+ file_bytes = semirepo_file_resolver.by_sha256(sha256)
+ else:
+ try:
+ url = urljoin(repo_url, f'file/sha256/{sha256}')
+ response = requests.get(url)
+
+ assert response.ok
+
+ file_bytes = response.content
+ except:
+ raise state.FileMissingError(
+ repo_id = str(repo_id),
+ sha256 = sha256
+ )
+
+ computed_sha256 = hashlib.sha256(file_bytes).digest().hex()
+ if computed_sha256 != sha256:
+ raise state.FileIntegrityError(
+ repo_id = str(repo_id),
+ sha256 = sha256,
+ invalid_sha256 = computed_sha256
+ )
+
+ cursor.execute(
+ '''
+ UPDATE
+ files
+ SET
+ data = ?
+ WHERE
+ file_id = ?;
+ ''',
+ (file_bytes, file_id)
+ )
diff --git a/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py b/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py
new file mode 100644
index 0000000..97f9de6
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py
@@ -0,0 +1,461 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (update of dependency tree in the db).
+#
+# 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.
+
+"""
+....
+"""
+
+import sqlite3
+import typing as t
+
+from .... import item_infos
+from ... import simple_dependency_satisfying as sds
+from .. import base
+from .pull_missing_files import pull_missing_files, FileResolver, \
+ DummyFileResolver
+
+
+AnyInfoVar = t.TypeVar(
+ 'AnyInfoVar',
+ item_infos.ResourceInfo,
+ item_infos.MappingInfo
+)
+
+def _get_infos_of_type(cursor: sqlite3.Cursor, info_type: t.Type[AnyInfoVar],) \
+ -> t.Mapping[int, AnyInfoVar]:
+ join_mapping_statuses = 'JOIN mapping_statuses AS ms USING (item_id)'
+ condition = "i.type = 'M' AND ms.enabled != 'D'"
+ if info_type is item_infos.ResourceInfo:
+ join_mapping_statuses = ''
+ condition = "i.type = 'R'"
+
+ cursor.execute(
+ f'''
+ SELECT
+ ive.item_version_id,
+ ive.definition,
+ ive.repo,
+ ive.repo_iteration
+ FROM
+ item_versions_extra AS ive
+ JOIN items AS i USING (item_id)
+ {join_mapping_statuses}
+ WHERE
+ {condition};
+ '''
+ )
+
+ result: dict[int, AnyInfoVar] = {}
+
+ for item_version_id, definition, repo_name, repo_iteration \
+ in cursor.fetchall():
+ info = info_type.load(definition, repo_name, repo_iteration)
+ if info.compatible:
+ result[item_version_id] = info
+
+ return result
+
+def _get_current_required_state(
+ cursor: sqlite3.Cursor,
+ unlocked_required_mappings: t.Sequence[int]
+) -> tuple[list[sds.MappingRequirement], list[sds.ResourceVersionRequirement]]:
+ # For mappings explicitly enabled by the user (+ all mappings they
+ # recursively depend on) let's make sure that their exact same versions will
+ # be enabled after the change. Make exception for mappings specified by the
+ # caller.
+ # The mappings to make exception for are passed by their item_id's. First,
+ # we compute a set of their corresponding item_version_id's.
+ with base.temporary_ids_tables(
+ cursor = cursor,
+ tables = [
+ ('__work_ids_0', unlocked_required_mappings),
+ ('__work_ids_1', []),
+ ('__unlocked_ids', [])
+ ]
+ ):
+ cursor.execute(
+ '''
+ INSERT INTO
+ __work_ids_1
+ SELECT
+ item_version_id
+ FROM
+ item_versions
+ WHERE
+ item_id IN __work_ids_0;
+ '''
+ )
+
+ # Recursively update the our unlocked ids collection with all mapping
+ # version ids that are required by mapping versions already referenced
+ # there.
+ work_tab = '__work_ids_1'
+ next_tab = '__work_ids_0'
+
+ while True:
+ cursor.execute(f'SELECT COUNT(*) FROM {work_tab};')
+
+ (count,), = cursor.fetchall()
+
+ if count == 0:
+ break
+
+ cursor.execute(f'DELETE FROM {next_tab};')
+
+ cursor.execute(
+ f'''
+ INSERT INTO
+ {next_tab}
+ SELECT
+ item_version_id
+ FROM
+ item_versions AS iv
+ JOIN items AS i
+ USING (item_id)
+ JOIN mapping_statuses AS ms
+ USING (item_id)
+ JOIN resolved_required_mappings AS rrm
+ ON iv.item_version_id = rrm.required_mapping_id
+ WHERE
+ ms.enabled != 'E' AND
+ rrm.requiring_mapping_id IN {work_tab} AND
+ rrm.requiring_mapping_id NOT IN __unlocked_ids;
+ '''
+ )
+
+ cursor.execute(
+ f'''
+ INSERT OR IGNORE INTO
+ __unlocked_ids
+ SELECT
+ id
+ FROM
+ {work_tab};
+ '''
+ )
+
+ work_tab, next_tab = next_tab, work_tab
+
+ # Describe all required mappings using requirement objects.
+ cursor.execute(
+ '''
+ SELECT
+ ive.definition, ive.repo, ive.repo_iteration
+ FROM
+ item_versions_extra AS ive
+ JOIN items AS i USING (item_id)
+ WHERE
+ i.type = 'M' AND
+ ive.item_version_id NOT IN __unlocked_ids AND
+ ive.active = 'R';
+ ''',
+ )
+
+ rows = cursor.fetchall()
+
+ mapping_requirements: list[sds.MappingRequirement] = []
+
+ for definition, repo, iteration in rows:
+ mapping_info = \
+ item_infos.MappingInfo.load(definition, repo, iteration)
+ mapping_req = sds.MappingVersionRequirement(
+ identifier = mapping_info.identifier,
+ version_info = mapping_info
+ )
+ mapping_requirements.append(mapping_req)
+
+ # Describe all required resources using requirement objects.
+ cursor.execute(
+ '''
+ SELECT
+ i_m.identifier,
+ ive_r.definition, ive_r.repo, ive_r.repo_iteration
+ FROM
+ resolved_depended_resources AS rdd
+ JOIN item_versions_extra AS ive_r
+ ON rdd.resource_item_id = ive_r.item_version_id
+ JOIN payloads AS p
+ USING (payload_id)
+ JOIN item_versions AS iv_m
+ ON p.mapping_item_id = iv_m.item_version_id
+ JOIN items AS i_m
+ ON iv_m.item_id = i_m.item_id
+ WHERE
+ iv_m.item_version_id NOT IN __unlocked_ids AND
+ iv_m.active = 'R';
+ ''',
+ )
+
+ rows = cursor.fetchall()
+
+ resource_requirements: list[sds.ResourceVersionRequirement] = []
+
+ for mapping_identifier, definition, repo, iteration in rows:
+ resource_info = \
+ item_infos.ResourceInfo.load(definition, repo, iteration)
+ resource_req = sds.ResourceVersionRequirement(
+ mapping_identifier = mapping_identifier,
+ version_info = resource_info
+ )
+ resource_requirements.append(resource_req)
+
+ return (mapping_requirements, resource_requirements)
+
+def _mark_version_installed(cursor: sqlite3.Cursor, version_id: int) -> None:
+ cursor.execute(
+ '''
+ UPDATE
+ item_versions
+ SET
+ installed = 'I'
+ WHERE
+ item_version_id = ?;
+ ''',
+ (version_id,)
+ )
+
+def _recompute_dependencies_no_state_update_no_pull_files(
+ cursor: sqlite3.Cursor,
+ unlocked_required_mappings: base.NoLockArg = [],
+) -> None:
+ cursor.execute('DELETE FROM payloads;')
+
+ ids_to_resources = _get_infos_of_type(cursor, item_infos.ResourceInfo)
+ ids_to_mappings = _get_infos_of_type(cursor, item_infos.MappingInfo)
+
+ resources_to_ids = dict((info, id) for id, info in ids_to_resources.items())
+ mappings_to_ids = dict((info, id) for id, info in ids_to_mappings.items())
+
+ if unlocked_required_mappings != 'all_mappings_unlocked':
+ mapping_reqs, resource_reqs = _get_current_required_state(
+ cursor = cursor,
+ unlocked_required_mappings = unlocked_required_mappings
+ )
+ else:
+ mapping_reqs, resource_reqs = [], []
+
+ cursor.execute(
+ '''
+ SELECT
+ i.identifier
+ FROM
+ mapping_statuses AS ms
+ JOIN items AS i USING(item_id)
+ WHERE
+ ms.enabled = 'E' AND ms.frozen = 'N';
+ '''
+ )
+
+ for mapping_identifier, in cursor.fetchall():
+ mapping_reqs.append(sds.MappingRequirement(mapping_identifier))
+
+ cursor.execute(
+ '''
+ SELECT
+ active_version_id, frozen
+ FROM
+ mapping_statuses
+ WHERE
+ enabled = 'E' AND frozen IN ('R', 'E');
+ '''
+ )
+
+ for active_version_id, frozen in cursor.fetchall():
+ info = ids_to_mappings[active_version_id]
+
+ requirement: sds.MappingRequirement
+
+ if frozen == 'R':
+ requirement = sds.MappingRepoRequirement(info.identifier, info.repo)
+ else:
+ requirement = sds.MappingVersionRequirement(info.identifier, info)
+
+ mapping_reqs.append(requirement)
+
+ mapping_choices = sds.compute_payloads(
+ resources = ids_to_resources.values(),
+ mappings = ids_to_mappings.values(),
+ mapping_requirements = mapping_reqs,
+ resource_requirements = resource_reqs
+ )
+
+ cursor.execute(
+ '''
+ UPDATE
+ mapping_statuses
+ SET
+ active_version_id = NULL
+ WHERE
+ enabled != 'E';
+ '''
+ )
+
+ cursor.execute("UPDATE item_versions SET active = 'N';")
+
+ cursor.execute('DELETE FROM payloads;')
+
+ cursor.execute('DELETE FROM resolved_required_mappings;')
+
+ for choice in mapping_choices.values():
+ mapping_ver_id = mappings_to_ids[choice.info]
+
+ if choice.required:
+ _mark_version_installed(cursor, mapping_ver_id)
+
+ cursor.execute(
+ '''
+ SELECT
+ item_id
+ FROM
+ item_versions
+ WHERE
+ item_version_id = ?;
+ ''',
+ (mapping_ver_id,)
+ )
+
+ (mapping_item_id,), = cursor.fetchall()
+
+ cursor.execute(
+ '''
+ UPDATE
+ mapping_statuses
+ SET
+ active_version_id = ?
+ WHERE
+ item_id = ?;
+ ''',
+ (mapping_ver_id, mapping_item_id)
+ )
+
+ cursor.execute(
+ '''
+ UPDATE
+ item_versions
+ SET
+ active = ?
+ WHERE
+ item_version_id = ?;
+ ''',
+ ('R' if choice.required else 'A', mapping_ver_id)
+ )
+
+ for depended_mapping_info in choice.mapping_dependencies:
+ cursor.execute(
+ '''
+ INSERT INTO resolved_required_mappings(
+ requiring_mapping_id,
+ required_mapping_id
+ )
+ VALUES (?, ?);
+ ''',
+ (mapping_ver_id, mappings_to_ids[depended_mapping_info])
+ )
+
+ for num, (pattern, payload) in enumerate(choice.payloads.items()):
+ cursor.execute(
+ '''
+ INSERT INTO payloads(
+ mapping_item_id,
+ pattern,
+ eval_allowed,
+ cors_bypass_allowed
+ )
+ VALUES (?, ?, ?, ?);
+ ''',
+ (
+ mapping_ver_id,
+ pattern,
+ payload.allows_eval,
+ payload.allows_cors_bypass
+ )
+ )
+
+ cursor.execute(
+ '''
+ SELECT
+ payload_id
+ FROM
+ payloads
+ WHERE
+ mapping_item_id = ? AND pattern = ?;
+ ''',
+ (mapping_ver_id, pattern)
+ )
+
+ (payload_id,), = cursor.fetchall()
+
+ for res_num, resource_info in enumerate(payload.resources):
+ resource_ver_id = resources_to_ids[resource_info]
+
+ if choice.required:
+ _mark_version_installed(cursor, resource_ver_id)
+
+ cursor.execute(
+ '''
+ INSERT INTO resolved_depended_resources(
+ payload_id,
+ resource_item_id,
+ idx
+ )
+ VALUES(?, ?, ?);
+ ''',
+ (payload_id, resource_ver_id, res_num)
+ )
+
+ new_status = 'R' if choice.required else 'A'
+
+ cursor.execute(
+ '''
+ UPDATE
+ item_versions
+ SET
+ active = (
+ CASE
+ WHEN active = 'R' OR ? = 'R' THEN 'R'
+ WHEN active = 'A' OR ? = 'A' THEN 'A'
+ ELSE 'N'
+ END
+ )
+ WHERE
+ item_version_id = ?;
+ ''',
+ (new_status, new_status, resource_ver_id)
+ )
+
+
+def _recompute_dependencies_no_state_update(
+ cursor: sqlite3.Cursor,
+ unlocked_required_mappings: base.NoLockArg = [],
+ semirepo_file_resolver: FileResolver = DummyFileResolver()
+) -> None:
+ _recompute_dependencies_no_state_update_no_pull_files(
+ cursor = cursor,
+ unlocked_required_mappings = unlocked_required_mappings
+ )
+
+ pull_missing_files(cursor, semirepo_file_resolver)
diff --git a/src/hydrilla/proxy/state_impl/base.py b/src/hydrilla/proxy/state_impl/base.py
new file mode 100644
index 0000000..f8291d8
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/base.py
@@ -0,0 +1,280 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (definition of fields of a class that
+# will implement HaketiloState).
+#
+# 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 defines fields that will later be part of a concrete HaketiloState
+subtype.
+"""
+
+import sqlite3
+import threading
+import secrets
+import webbrowser
+import dataclasses as dc
+import typing as t
+
+from pathlib import Path
+from contextlib import contextmanager
+from abc import abstractmethod
+
+from ... import url_patterns
+from ... import pattern_tree
+from .. import simple_dependency_satisfying as sds
+from .. import state as st
+from .. import policies
+
+
+@contextmanager
+def temporary_ids_tables(
+ cursor: sqlite3.Cursor,
+ tables: t.Iterable[tuple[str, t.Iterable[int]]]
+) -> t.Iterator[None]:
+ created: set[str] = set()
+
+ try:
+ for name, ids in tables:
+ cursor.execute(
+ f'CREATE TEMPORARY TABLE "{name}"(id INTEGER PRIMARY KEY);'
+ )
+ created.add(name)
+
+ for id in ids:
+ cursor.execute(f'INSERT INTO "{name}" VALUES(?);', (id,))
+
+ yield
+ finally:
+ for name in created:
+ cursor.execute(f'DROP TABLE "{name}";')
+
+
+@dc.dataclass(frozen=True)
+class PolicyTree(pattern_tree.PatternTree[policies.PolicyFactory]):
+ SelfType = t.TypeVar('SelfType', bound='PolicyTree')
+
+ def register_payload(
+ self: 'SelfType',
+ pattern: url_patterns.ParsedPattern,
+ payload_key: st.PayloadKey,
+ token: str
+ ) -> 'SelfType':
+ payload_policy_factory = policies.PayloadPolicyFactory(
+ builtin = False,
+ payload_key = payload_key
+ )
+
+ policy_tree = self.register(pattern, payload_policy_factory)
+
+ resource_policy_factory = policies.PayloadResourcePolicyFactory(
+ builtin = False,
+ payload_key = payload_key
+ )
+
+ policy_tree = policy_tree.register(
+ pattern.path_append(token, '***'),
+ resource_policy_factory
+ )
+
+ return policy_tree
+
+def mark_failed_file_installs(
+ cursor: sqlite3.Cursor,
+ file_sha256: str,
+ repo_id: int
+) -> None:
+ cursor.execute(
+ '''
+ WITH failed_items AS (
+ SELECT DISTINCT
+ item_version_id
+ FROM
+ files AS f
+ JOIN file_uses AS fu USING (file_id)
+ JOIN item_versions_extra AS ive USING (item_version_id)
+ WHERE
+ f.sha256 = ? AND f.data IS NULL AND ive.repo_id = ?
+ )
+ UPDATE
+ item_versions
+ SET
+ installed = 'F'
+ WHERE
+ item_version_id IN failed_items;
+ ''',
+ (file_sha256, repo_id)
+ )
+
+NoLockArg = t.Union[t.Sequence[int], t.Literal['all_mappings_unlocked']]
+
+PayloadsData = t.Mapping[st.PayloadRef, st.PayloadData]
+
+# mypy needs to be corrected:
+# https://stackoverflow.com/questions/70999513/conflict-between-mix-ins-for-abstract-dataclasses/70999704#70999704
+@dc.dataclass # type: ignore[misc]
+class HaketiloStateWithFields(st.HaketiloState):
+ """...."""
+ store_dir: Path
+ _listen_host: str
+ _listen_port: int
+ _logger: st.Logger
+ connection: sqlite3.Connection
+ settings: st.HaketiloGlobalSettings
+ current_cursor: t.Optional[sqlite3.Cursor] = None
+
+ secret: bytes = dc.field(default_factory=(lambda: secrets.token_bytes(16)))
+
+ policy_tree: PolicyTree = PolicyTree()
+ payloads_data: PayloadsData = dc.field(default_factory=dict)
+
+ lock: threading.RLock = dc.field(default_factory=threading.RLock)
+
+ @contextmanager
+ def cursor(self, transaction: bool = False) \
+ -> t.Iterator[sqlite3.Cursor]:
+ with self.lock:
+ start_transaction = \
+ transaction and not self.connection.in_transaction
+
+ if self.current_cursor is not None:
+ yield self.current_cursor
+ return
+
+ try:
+ self.current_cursor = self.connection.cursor()
+
+ if start_transaction:
+ self.current_cursor.execute('BEGIN TRANSACTION;')
+
+ try:
+ yield self.current_cursor
+
+ if start_transaction:
+ assert self.connection.in_transaction
+ self.current_cursor.execute('COMMIT TRANSACTION;')
+ except:
+ if start_transaction:
+ self.current_cursor.execute('ROLLBACK TRANSACTION;')
+ raise
+ except st.FileInstallationError as ex:
+ if start_transaction:
+ assert self.current_cursor is not None
+ mark_failed_file_installs(
+ cursor = self.current_cursor,
+ file_sha256 = ex.sha256,
+ repo_id = int(ex.repo_id)
+ )
+ raise
+ finally:
+ self.current_cursor = None
+
+ def select_policy(self, url: url_patterns.ParsedUrl) -> policies.Policy:
+ """...."""
+ with self.lock:
+ policy_tree = self.policy_tree
+
+ try:
+ best_priority: int = 0
+ best_policy: t.Optional[policies.Policy] = None
+
+ for factories_set in policy_tree.search(url):
+ for stored_factory in sorted(factories_set):
+ factory = stored_factory.item
+
+ policy = factory.make_policy(self)
+
+ if policy.priority > best_priority:
+ best_priority = policy.priority
+ best_policy = policy
+ except Exception as e:
+ return policies.ErrorBlockPolicy(self.settings, error=e)
+
+ if best_policy is not None:
+ return best_policy
+
+ if self.settings.default_allow_scripts:
+ return policies.FallbackAllowPolicy(self.settings)
+ else:
+ return policies.FallbackBlockPolicy(self.settings)
+
+ @abstractmethod
+ def import_items(self, malcontent_path: Path, repo_id: int = 1) -> None:
+ ...
+
+ @abstractmethod
+ def soft_prune_orphan_items(self) -> None:
+ ...
+
+ @abstractmethod
+ def recompute_dependencies(
+ self,
+ unlocked_required_mappings: NoLockArg = []
+ ) -> None:
+ ...
+
+ @abstractmethod
+ def pull_missing_files(self) -> None:
+ """
+ This function checks which packages marked as installed are missing
+ files in the database. It attempts to restore integrity by downloading
+ the files from their respective repositories.
+ """
+ ...
+
+ @abstractmethod
+ def rebuild_structures(self, *, payloads: bool = True, rules: bool = True) \
+ -> None:
+ """
+ Recreation of data structures as done after every recomputation of
+ dependencies as well as at startup.
+ """
+ ...
+
+ @property
+ def listen_host(self) -> str:
+ if self._listen_host != '0.0.0.0':
+ return '127.0.0.1'
+
+ return self._listen_host
+
+ @property
+ def listen_port(self) -> int:
+ return self._listen_port
+
+ @property
+ def efective_listen_addr(self) -> str:
+ effective_host = self._listen_host
+ if self._listen_host == '0.0.0.0':
+ effective_host = '127.0.0.1'
+
+ return f'http://{effective_host}:{self._listen_port}'
+
+ def launch_browser(self) -> bool:
+ return webbrowser.open(self.efective_listen_addr)
+
+ @property
+ def logger(self) -> st.Logger:
+ return self._logger
diff --git a/src/hydrilla/proxy/state_impl/concrete_state.py b/src/hydrilla/proxy/state_impl/concrete_state.py
new file mode 100644
index 0000000..89a2eb2
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/concrete_state.py
@@ -0,0 +1,523 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (instantiatable HaketiloState subtype).
+#
+# 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 contains logic for keeping track of all settings, rules, mappings
+and resources.
+"""
+
+import sqlite3
+import secrets
+import typing as t
+import dataclasses as dc
+
+from pathlib import Path
+
+from ...exceptions import HaketiloException
+from ...translations import smart_gettext as _
+from ... import url_patterns
+from ... import item_infos
+from .. import state as st
+from .. import policies
+from .. import simple_dependency_satisfying as sds
+from . import base
+from . import rules
+from . import items
+from . import repos
+from . import payloads
+from . import _operations
+
+
+here = Path(__file__).resolve().parent
+
+
+def _add_popup_settings_columns(cursor: sqlite3.Cursor) -> None:
+ for page_type in ('jsallowed', 'jsblocked', 'payloadon'):
+ cursor.execute(
+ f'''
+ ALTER TABLE general ADD COLUMN
+ default_popup_{page_type}_onkeyboard BOOLEAN NOT NULL DEFAULT TRUE;
+ '''
+ )
+ cursor.execute(
+ f'''
+ ALTER TABLE general ADD COLUMN
+ default_popup_{page_type}_style CHAR(1) NOT NULL DEFAULT 'T'
+ CHECK (default_popup_{page_type}_style IN ('D', 'T'));
+ '''
+ )
+
+def _add_locale_column(cursor: sqlite3.Cursor) -> None:
+ cursor.execute(
+ '''
+ ALTER TABLE general ADD COLUMN
+ locale VARCHAR NOT NULL DEFAULT 'unknown';
+ '''
+ )
+
+def _add_update_waiting_column(cursor: sqlite3.Cursor) -> None:
+ cursor.execute(
+ '''
+ ALTER TABLE general ADD COLUMN
+ update_waiting BOOLEAN NOT NULL DEFAULT TRUE;
+ '''
+ )
+
+def _prepare_database(connection: sqlite3.Connection) -> None:
+ cursor = connection.cursor()
+
+ try:
+ cursor.execute(
+ '''
+ SELECT
+ COUNT(name)
+ FROM
+ sqlite_master
+ WHERE
+ name = 'general' AND type = 'table';
+ '''
+ )
+
+ (db_initialized,), = cursor.fetchall()
+
+ if not db_initialized:
+ cursor.executescript((here / 'tables.sql').read_text())
+
+ cursor.execute('BEGIN TRANSACTION;')
+
+ try:
+ if db_initialized:
+ # If db was initialized before we connected to it, we must check
+ # what its schema version is.
+ cursor.execute(
+ '''
+ SELECT
+ haketilo_version
+ FROM
+ general;
+ '''
+ )
+
+ (db_haketilo_version,) = cursor.fetchone()
+ if db_haketilo_version != '3.0b1':
+ raise HaketiloException(_('err.proxy.unknown_db_schema'))
+
+ popup_settings_columns_present = False
+ locale_column_present = False
+ update_waiting_column_present = False
+
+ cursor.execute("PRAGMA TABLE_INFO('general')")
+ for __cid, name, __type, __notnull, __dflt_value, __pk \
+ in cursor.fetchall():
+ if name == 'default_popup_jsallowed_onkeyboard':
+ popup_settings_columns_present = True
+
+ if name == 'locale':
+ locale_column_present = True
+
+ if name == 'update_waiting':
+ update_waiting_column_present = True
+
+ if not popup_settings_columns_present:
+ _add_popup_settings_columns(cursor)
+
+ if not locale_column_present:
+ _add_locale_column(cursor)
+
+ if not update_waiting_column_present:
+ _add_update_waiting_column(cursor)
+
+ cursor.execute('COMMIT TRANSACTION;')
+ except:
+ cursor.execute('ROLLBACK TRANSACTION;')
+ raise
+
+ cursor.execute('PRAGMA FOREIGN_KEYS;')
+ if cursor.fetchall() == []:
+ raise HaketiloException(_('err.proxy.no_sqlite_foreign_keys'))
+
+ cursor.execute('PRAGMA FOREIGN_KEYS=ON;')
+ finally:
+ cursor.close()
+
+
+def load_settings(cursor: sqlite3.Cursor) -> st.HaketiloGlobalSettings:
+ cursor.execute(
+ '''
+ SELECT
+ default_allow_scripts,
+ advanced_user,
+ repo_refresh_seconds,
+ mapping_use_mode,
+ locale,
+ update_waiting
+ FROM
+ general;
+ '''
+ )
+
+ (default_allow_scripts, advanced_user, repo_refresh_seconds,
+ mapping_use_mode, locale, update_waiting), = cursor.fetchall()
+
+ popup_settings_dict = {}
+
+ for page_type in ('jsallowed', 'jsblocked', 'payloadon'):
+ try:
+ cursor.execute(
+ f'''
+ SELECT
+ default_popup_{page_type}_onkeyboard,
+ default_popup_{page_type}_style
+ FROM
+ general;
+ '''
+ )
+
+ (onkeyboard, style), = cursor.fetchall()
+ except:
+ onkeyboard, style = True, 'T'
+
+ popup_settings_dict[f'default_popup_{page_type}'] = st.PopupSettings(
+ keyboard_trigger = onkeyboard,
+ style = st.PopupStyle(style)
+ )
+
+ return st.HaketiloGlobalSettings(
+ default_allow_scripts = default_allow_scripts,
+ advanced_user = advanced_user,
+ repo_refresh_seconds = repo_refresh_seconds,
+ mapping_use_mode = st.MappingUseMode(mapping_use_mode),
+ locale = locale,
+ update_waiting = update_waiting,
+
+ **popup_settings_dict
+ )
+
+@dc.dataclass
+class ConcreteHaketiloState(base.HaketiloStateWithFields):
+ def __post_init__(self) -> None:
+ self.rebuild_structures()
+
+ def import_items(self, malcontent_path: Path, repo_id: int = 1) -> None:
+ with self.cursor(transaction=(repo_id == 1)) as cursor:
+ # This method without the repo_id argument exposed is part of the
+ # state API. As such, calls with repo_id = 1 (imports of local
+ # semirepo packages) create a new transaction. Calls with different
+ # values of repo_id are assumed to originate from within the state
+ # implementation code and expect an existing transaction. Here, we
+ # verify the transaction is indeed present.
+ assert self.connection.in_transaction
+
+ _operations._load_packages_no_state_update(
+ cursor = cursor,
+ malcontent_path = malcontent_path,
+ repo_id = repo_id
+ )
+
+ cursor.execute('UPDATE general SET update_waiting = TRUE;')
+ self.settings = dc.replace(self.settings, update_waiting=True)
+
+ self.rebuild_structures(rules=False)
+
+ def count_orphan_items(self) -> st.OrphanItemsStats:
+ with self.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ COALESCE(SUM(i.type = 'M'), 0),
+ COALESCE(SUM(i.type = 'R'), 0)
+ FROM
+ item_versions AS iv
+ JOIN items AS i USING (item_id)
+ JOIN orphan_iterations AS oi USING (repo_iteration_id)
+ WHERE
+ iv.active != 'R';
+ '''
+ )
+
+ (orphan_mappings, orphan_resources), = cursor.fetchall()
+
+ return st.OrphanItemsStats(orphan_mappings, orphan_resources)
+
+ def prune_orphan_items(self) -> None:
+ with self.cursor(transaction=True) as cursor:
+ _operations.prune_orphans(cursor, aggressive=True)
+
+ self.recompute_dependencies()
+
+ def soft_prune_orphan_items(self) -> None:
+ with self.cursor() as cursor:
+ assert self.connection.in_transaction
+
+ _operations.prune_orphans(cursor)
+
+ def recompute_dependencies(
+ self,
+ unlocked_required_mappings: base.NoLockArg = []
+ ) -> None:
+ with self.cursor() as cursor:
+ assert self.connection.in_transaction
+
+ _operations._recompute_dependencies_no_state_update(
+ cursor = cursor,
+ unlocked_required_mappings = unlocked_required_mappings
+ )
+
+ if unlocked_required_mappings == 'all_mappings_unlocked':
+ cursor.execute('UPDATE general SET update_waiting = FALSE;')
+ self.settings = dc.replace(self.settings, update_waiting=False)
+
+ self.rebuild_structures(rules=False)
+
+ def upate_all_items(self) -> None:
+ with self.cursor(transaction=True):
+ self.recompute_dependencies('all_mappings_unlocked')
+
+ def pull_missing_files(self) -> None:
+ with self.cursor() as cursor:
+ assert self.connection.in_transaction
+
+ _operations.pull_missing_files(cursor)
+
+ def _rebuild_structures(self, cursor: sqlite3.Cursor) -> None:
+ new_policy_tree = base.PolicyTree()
+
+ web_ui_main_pattern = 'http*://hkt.mitm.it/***'
+ web_ui_main_factory = policies.WebUIMainPolicyFactory(builtin=True)
+
+ for parsed_pattern in url_patterns.parse_pattern(web_ui_main_pattern):
+ new_policy_tree = new_policy_tree.register(
+ parsed_pattern = parsed_pattern,
+ item = web_ui_main_factory
+ )
+
+ web_ui_landing_pattern = f'{self.efective_listen_addr}/***'
+ web_ui_landing_factory = policies.WebUILandingPolicyFactory(
+ builtin = True
+ )
+
+ try:
+ parsed_pattern, = url_patterns.parse_pattern(web_ui_landing_pattern)
+ except url_patterns.HaketiloURLException:
+ fmt = _('warn.proxy.failed_to_register_landing_page_at_{}')
+ self.logger.warn(fmt.format(web_ui_landing_pattern))
+ else:
+ new_policy_tree = new_policy_tree.register(
+ parsed_pattern = parsed_pattern,
+ item = web_ui_landing_factory
+ )
+
+ mitm_it_page_pattern = 'http://mitm.it/***'
+ mitm_it_page_factory = policies.MitmItPagePolicyFactory()
+
+ parsed_pattern, = url_patterns.parse_pattern(mitm_it_page_pattern)
+ new_policy_tree = new_policy_tree.register(
+ parsed_pattern = parsed_pattern,
+ item = mitm_it_page_factory
+ )
+
+ # Put script blocking/allowing rules in policy tree.
+ cursor.execute('SELECT pattern, allow_scripts FROM rules;')
+
+ for pattern, allow_scripts in cursor.fetchall():
+ for parsed_pattern in url_patterns.parse_pattern(pattern):
+ factory: policies.PolicyFactory
+ if allow_scripts:
+ factory = policies.RuleAllowPolicyFactory(
+ builtin = False,
+ pattern = parsed_pattern
+ )
+ else:
+ factory = policies.RuleBlockPolicyFactory(
+ builtin = False,
+ pattern = parsed_pattern
+ )
+
+ new_policy_tree = new_policy_tree.register(
+ parsed_pattern = parsed_pattern,
+ item = factory
+ )
+
+ # Put script payload rules in policy tree.
+ cursor.execute(
+ '''
+ SELECT
+ p.payload_id,
+ p.pattern,
+ p.eval_allowed,
+ p.cors_bypass_allowed,
+ ms.enabled,
+ i.identifier
+ FROM
+ payloads AS p
+ JOIN item_versions AS iv
+ ON p.mapping_item_id = iv.item_version_id
+ JOIN items AS i
+ USING (item_id)
+ JOIN mapping_statuses AS ms
+ USING (item_id);
+ '''
+ )
+
+ new_payloads_data: dict[st.PayloadRef, st.PayloadData] = {}
+
+ for (payload_id_int, pattern, eval_allowed, cors_bypass_allowed,
+ enabled_status, identifier) in cursor.fetchall():
+ payload_ref = payloads.ConcretePayloadRef(str(payload_id_int), self)
+
+ previous_data = self.payloads_data.get(payload_ref)
+ if previous_data is not None:
+ token = previous_data.unique_token
+ else:
+ token = secrets.token_urlsafe(8)
+
+ payload_key = st.PayloadKey(payload_ref, identifier)
+
+ for parsed_pattern in url_patterns.parse_pattern(pattern):
+ new_policy_tree = new_policy_tree.register_payload(
+ parsed_pattern,
+ payload_key,
+ token
+ )
+
+ pattern_path_segments = parsed_pattern.path_segments
+
+ payload_data = st.PayloadData(
+ ref = payload_ref,
+ explicitly_enabled = enabled_status == 'E',
+ unique_token = token,
+ mapping_identifier = identifier,
+ pattern = pattern,
+ pattern_path_segments = pattern_path_segments,
+ eval_allowed = eval_allowed,
+ cors_bypass_allowed = cors_bypass_allowed,
+ global_secret = self.secret
+ )
+
+ new_payloads_data[payload_ref] = payload_data
+
+ self.policy_tree = new_policy_tree
+ self.payloads_data = new_payloads_data
+
+ def rebuild_structures(self, *, payloads: bool = True, rules: bool = True) \
+ -> None:
+ # The `payloads` and `rules` args will be useful for optimization but
+ # for now we're not yet using them.
+ with self.cursor() as cursor:
+ self._rebuild_structures(cursor)
+
+ def rule_store(self) -> st.RuleStore:
+ return rules.ConcreteRuleStore(self)
+
+ def repo_store(self) -> st.RepoStore:
+ return repos.ConcreteRepoStore(self)
+
+ def mapping_store(self) -> st.MappingStore:
+ return items.ConcreteMappingStore(self)
+
+ def mapping_version_store(self) -> st.MappingVersionStore:
+ return items.ConcreteMappingVersionStore(self)
+
+ def resource_store(self) -> st.ResourceStore:
+ return items.ConcreteResourceStore(self)
+
+ def resource_version_store(self) -> st.ResourceVersionStore:
+ return items.ConcreteResourceVersionStore(self)
+
+ def payload_store(self) -> st.PayloadStore:
+ return payloads.ConcretePayloadStore(self)
+
+ def get_secret(self) -> bytes:
+ return self.secret
+
+ def get_settings(self) -> st.HaketiloGlobalSettings:
+ with self.lock:
+ return self.settings
+
+ def update_settings(
+ self,
+ *,
+ mapping_use_mode: t.Optional[st.MappingUseMode] = None,
+ default_allow_scripts: t.Optional[bool] = None,
+ advanced_user: t.Optional[bool] = None,
+ repo_refresh_seconds: t.Optional[int] = None,
+ locale: t.Optional[str] = None,
+ default_popup_settings: t.Mapping[str, st.PopupSettings] = {}
+ ) -> None:
+ with self.cursor(transaction=True) as cursor:
+ def set_opt(col_name: str, val: t.Union[bool, int, str]) -> None:
+ cursor.execute(f'UPDATE general SET {col_name} = ?;', (val,))
+
+ if mapping_use_mode is not None:
+ set_opt('mapping_use_mode', mapping_use_mode.value)
+ if default_allow_scripts is not None:
+ set_opt('default_allow_scripts', default_allow_scripts)
+ if advanced_user is not None:
+ set_opt('advanced_user', advanced_user)
+ if repo_refresh_seconds is not None:
+ set_opt('repo_refresh_seconds', repo_refresh_seconds)
+ if locale is not None:
+ set_opt('locale', locale)
+
+ for page_type in ('jsallowed', 'jsblocked', 'payloadon'):
+ popup_settings = default_popup_settings.get(page_type)
+ if popup_settings is not None:
+ trigger_col_name = f'default_popup_{page_type}_onkeyboard'
+ set_opt(trigger_col_name, popup_settings.keyboard_trigger)
+
+ style_col_name = f'default_popup_{page_type}_style'
+ set_opt(style_col_name, popup_settings.style.value)
+
+ self.settings = load_settings(cursor)
+
+ @staticmethod
+ def make(
+ store_dir: Path,
+ listen_host: str,
+ listen_port: int,
+ logger: st.Logger
+ ) -> 'ConcreteHaketiloState':
+ store_dir.mkdir(parents=True, exist_ok=True)
+
+ connection = sqlite3.connect(
+ str(store_dir / 'sqlite3.db'),
+ isolation_level = None,
+ check_same_thread = False
+ )
+
+ _prepare_database(connection)
+
+ global_settings = load_settings(connection.cursor())
+
+ return ConcreteHaketiloState(
+ store_dir = store_dir,
+ _logger = logger,
+ _listen_host = listen_host,
+ _listen_port = listen_port,
+ connection = connection,
+ settings = global_settings
+ )
diff --git a/src/hydrilla/proxy/state_impl/items.py b/src/hydrilla/proxy/state_impl/items.py
new file mode 100644
index 0000000..9fa12ab
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/items.py
@@ -0,0 +1,811 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (ResourceStore and MappingStore
+# implementations).
+#
+# 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 provides an interface to interact with mappings, and resources
+inside Haketilo.
+"""
+
+import sqlite3
+import typing as t
+import dataclasses as dc
+
+from contextlib import contextmanager
+from urllib.parse import urljoin
+
+from ... import item_infos
+from .. import state as st
+from . import base
+
+
+def _get_item_id(cursor: sqlite3.Cursor, item_type: str, identifier: str) \
+ -> str:
+ cursor.execute(
+ 'SELECT item_id FROM items WHERE identifier = ? AND type = ?;',
+ (identifier, item_type)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (item_id,), = rows
+
+ return str(item_id)
+
+
+def _get_parent_item_id(cursor: sqlite3.Cursor, version_id: str) -> str:
+ cursor.execute(
+ '''
+ SELECT
+ item_id
+ FROM
+ item_versions
+ WHERE
+ item_version_id = ?;
+ ''',
+ (version_id,)
+ )
+
+ rows = cursor.fetchall()
+ if rows == []:
+ raise st.MissingItemError()
+
+ (item_id,), = rows
+
+ return str(item_id)
+
+
+def _set_installed_status(cursor: sqlite3.Cursor, id: str, new_status: str) \
+ -> None:
+ cursor.execute(
+ 'UPDATE item_versions SET installed = ? WHERE item_version_id = ?;',
+ (new_status, id)
+ )
+
+def _get_statuses(cursor: sqlite3.Cursor, id: str) -> tuple[str, str]:
+ cursor.execute(
+ '''
+ SELECT
+ installed, active
+ FROM
+ item_versions
+ WHERE
+ item_version_id = ?;
+ ''',
+ (id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (installed_status, active_status), = rows
+
+ return installed_status, active_status
+
+VersionRefVar = t.TypeVar(
+ 'VersionRefVar',
+ 'ConcreteResourceVersionRef',
+ 'ConcreteMappingVersionRef'
+)
+
+def _install_version(ref: VersionRefVar) -> None:
+ with ref.state.cursor(transaction=True) as cursor:
+ installed_status, _ = _get_statuses(cursor, ref.id)
+
+ if installed_status == 'I':
+ return
+
+ _set_installed_status(cursor, ref.id, 'I')
+
+ ref.state.pull_missing_files()
+
+def _uninstall_version(ref: VersionRefVar) -> t.Optional[VersionRefVar]:
+ with ref.state.cursor(transaction=True) as cursor:
+ installed_status, active_status = _get_statuses(cursor, ref.id)
+
+ if installed_status == 'N':
+ return ref
+
+ if active_status == 'R':
+ return ref
+
+ _set_installed_status(cursor, ref.id, 'N')
+
+ ref.state.soft_prune_orphan_items()
+
+ if active_status != 'N':
+ ref.state.recompute_dependencies()
+
+ cursor.execute(
+ 'SELECT COUNT(*) FROM item_versions WHERE item_version_id = ?;',
+ (ref.id,)
+ )
+
+ (version_still_present,), = cursor.fetchall()
+ return ref if version_still_present else None
+
+
+def _get_file(ref: VersionRefVar, name: str, file_type: str = 'L') \
+ -> st.FileData:
+ with ref.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ f.data, fu.mime_type
+ FROM
+ item_versions AS iv
+ JOIN items AS i USING (item_id)
+ JOIN file_uses AS fu USING (item_version_id)
+ JOIN files AS f USING (file_id)
+ WHERE
+ (iv.item_version_id = ? AND iv.installed = 'I') AND
+ i.type = ? AND
+ (fu.name = ? AND fu.type = ?) AND
+ f.data IS NOT NULL;
+ ''',
+ (ref.id, ref.type.value[0].upper(), name, file_type)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (data, mime_type), = rows
+
+ return st.FileData(mime_type, name, data)
+
+
+def _get_upstream_file_url(
+ ref: VersionRefVar,
+ name: str,
+ file_type: str = 'L'
+) -> str:
+ with ref.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ f.sha256, r.url
+ FROM
+ item_versions AS iv
+ JOIN repo_iterations AS ri USING(repo_iteration_id)
+ JOIN repos AS r USING(repo_id)
+ JOIN file_uses AS fu USING(item_version_id)
+ JOIN files AS f USING(file_id)
+ WHERE
+ iv.item_version_id = ? AND
+ (fu.name = ? AND fu.type = ?) AND
+ r.url IS NOT NULL;
+ ''',
+ (ref.id, name, file_type)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (sha256, repo_url), = rows
+
+ return urljoin(repo_url, f'file/sha256/{sha256}')
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteMappingRef(st.MappingRef):
+ state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
+
+ def _get_status_data(self, cursor: sqlite3.Cursor) \
+ -> tuple[str, str, int]:
+ cursor.execute(
+ '''
+ SELECT
+ ms.enabled, ms.frozen, ms.active_version_id
+ FROM
+ mapping_statuses
+ WHERE
+ item_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (enabled_status, frozen_status, active_version_id), = rows
+
+ return (enabled_status, frozen_status, active_version_id)
+
+
+ def update_status(
+ self,
+ enabled: st.EnabledStatus,
+ frozen: t.Optional[st.FrozenStatus] = None,
+ version_id_to_activate: t.Optional[str] = None
+ ) -> None:
+ assert frozen is None or enabled == st.EnabledStatus.ENABLED
+ assert version_id_to_activate is None or \
+ frozen != st.FrozenStatus.NOT_FROZEN
+
+ with self.state.cursor(transaction=True) as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ enabled, frozen, active_version_id
+ FROM
+ mapping_statuses
+ WHERE
+ item_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (old_enabled_status, old_frozen_status,
+ old_active_version_id), = rows
+
+ if enabled.value == old_enabled_status and frozen is None:
+ return
+
+ new_enabled_status = enabled.value
+
+ new_frozen_status = None if frozen is None else frozen.value
+
+ if version_id_to_activate is not None:
+ new_active_version_id = version_id_to_activate
+ elif enabled == st.EnabledStatus.ENABLED and \
+ old_active_version_id is not None:
+ new_active_version_id = str(old_active_version_id)
+ else:
+ new_active_version_id = None
+
+ cursor.execute(
+ '''
+ UPDATE
+ mapping_statuses
+ SET
+ enabled = ?,
+ frozen = ?,
+ active_version_id = ?
+ WHERE
+ item_id = ?;
+ ''', (
+ new_enabled_status,
+ new_frozen_status,
+ new_active_version_id,
+ self.id
+ ))
+
+ if enabled == st.EnabledStatus.ENABLED:
+ if old_enabled_status == 'E' and \
+ new_active_version_id == str(old_active_version_id) and \
+ (new_frozen_status == 'E' or
+ old_frozen_status == 'N' or
+ new_frozen_status == old_frozen_status):
+ return
+ else:
+ if old_active_version_id is None and old_enabled_status != 'D':
+ return
+
+ self.state.recompute_dependencies([int(self.id)])
+
+ def get_display_info(self) -> st.RichMappingDisplayInfo:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ i.identifier,
+ ms.enabled, ms.frozen
+ FROM
+ items AS i
+ JOIN mapping_statuses AS ms USING (item_id)
+ WHERE
+ item_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (identifier, enabled_status, frozen_status), = rows
+
+ cursor.execute(
+ '''
+ SELECT
+ item_version_id,
+ definition,
+ repo,
+ repo_iteration,
+ installed,
+ active,
+ is_orphan,
+ is_local
+ FROM
+ item_versions_extra
+ WHERE
+ item_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ version_infos = []
+
+ active_info: t.Optional[st.MappingVersionDisplayInfo] = None
+
+ for (item_version_id, definition, repo, repo_iteration,
+ installed_status, active_status, is_orphan, is_local) in rows:
+ ref = ConcreteMappingVersionRef(str(item_version_id), self.state)
+
+ item_info = item_infos.MappingInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
+
+ version_display_info = st.MappingVersionDisplayInfo(
+ ref = ref,
+ info = item_info,
+ installed = st.InstalledStatus(installed_status),
+ active = st.ActiveStatus(active_status),
+ is_orphan = is_orphan,
+ is_local = is_local
+ )
+
+ version_infos.append(version_display_info)
+
+ if active_status in ('R', 'A'):
+ active_info = version_display_info
+
+ return st.RichMappingDisplayInfo(
+ ref = self,
+ identifier = identifier,
+ enabled = st.EnabledStatus(enabled_status),
+ frozen = st.FrozenStatus.make(frozen_status),
+ active_version = active_info,
+ all_versions = sorted(version_infos, key=(lambda vi: vi.info))
+ )
+
+
+@dc.dataclass(frozen=True)
+class ConcreteMappingStore(st.MappingStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.MappingRef:
+ return ConcreteMappingRef(str(int(id)), self.state)
+
+ def get_display_infos(self) -> t.Sequence[st.MappingDisplayInfo]:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ WITH available_item_ids AS (
+ SELECT DISTINCT item_id FROM item_versions
+ )
+ SELECT
+ i.item_id,
+ i.identifier,
+ ive.item_version_id,
+ ive.definition,
+ ive.repo,
+ ive.repo_iteration,
+ ive.installed,
+ ive.active,
+ ive.is_orphan,
+ ive.is_local,
+ ms.enabled,
+ ms.frozen
+ FROM
+ items AS i
+ JOIN mapping_statuses AS ms
+ USING (item_id)
+ LEFT JOIN item_versions_extra AS ive
+ ON ms.active_version_id = ive.item_version_id
+ WHERE
+ i.item_id IN available_item_ids;
+ '''
+ )
+
+ rows = cursor.fetchall()
+
+ result = []
+
+ for (item_id, identifier, item_version_id, definition, repo,
+ repo_iteration, installed_status, active_status, is_orphan,
+ is_local, enabled_status, frozen_status) in rows:
+ ref = ConcreteMappingRef(str(item_id), self.state)
+
+ active_version: t.Optional[st.MappingVersionDisplayInfo] = None
+
+ if item_version_id is not None:
+ active_version_ref = ConcreteMappingVersionRef(
+ id = str(item_version_id),
+ state = self.state
+ )
+
+ active_version_info = item_infos.MappingInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
+
+ active_version = st.MappingVersionDisplayInfo(
+ ref = active_version_ref,
+ info = active_version_info,
+ installed = st.InstalledStatus(installed_status),
+ active = st.ActiveStatus(active_status),
+ is_orphan = is_orphan,
+ is_local = is_local
+ )
+
+ display_info = st.MappingDisplayInfo(
+ ref = ref,
+ identifier = identifier,
+ enabled = st.EnabledStatus(enabled_status),
+ frozen = st.FrozenStatus.make(frozen_status),
+ active_version = active_version
+ )
+
+ result.append(display_info)
+
+ return sorted(result, key=(lambda di: di.identifier))
+
+ def get_by_identifier(self, identifier: str) -> st.MappingRef:
+ with self.state.cursor() as cursor:
+ item_id = _get_item_id(cursor, 'M', identifier)
+
+ return ConcreteMappingRef(item_id, self.state)
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteMappingVersionRef(st.MappingVersionRef):
+ state: base.HaketiloStateWithFields
+
+ def install(self) -> None:
+ return _install_version(self)
+
+ def uninstall(self) -> t.Optional['ConcreteMappingVersionRef']:
+ return _uninstall_version(self)
+
+ def ensure_depended_items_installed(self) -> None:
+ with self.state.cursor(transaction=True) as cursor:
+ cursor.execute(
+ '''
+ UPDATE
+ item_versions
+ SET
+ installed = 'I'
+ WHERE
+ item_version_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ cursor.execute(
+ '''
+ WITH depended_resource_ids AS (
+ SELECT
+ rdd.resource_item_id
+ FROM
+ payloads AS p
+ JOIN resolved_depended_resources AS rdd
+ USING (payload_id)
+ WHERE
+ p.mapping_item_id = ?
+ )
+ UPDATE
+ item_versions
+ SET
+ installed = 'I'
+ WHERE
+ item_version_id IN depended_resource_ids;
+ ''',
+ (self.id,)
+ )
+
+ self.state.pull_missing_files()
+
+ @contextmanager
+ def _mapping_ref(self) -> t.Iterator[ConcreteMappingRef]:
+ with self.state.cursor(transaction=True) as cursor:
+ mapping_id = _get_parent_item_id(cursor, self.id)
+ yield ConcreteMappingRef(mapping_id, self.state)
+
+ def update_mapping_status(
+ self,
+ enabled: st.EnabledStatus,
+ frozen: t.Optional[st.FrozenStatus] = None
+ ) -> None:
+ with self._mapping_ref() as mapping_ref:
+ id_to_pass: t.Optional[str] = self.id
+ if enabled.value != 'E' or frozen is None or frozen.value == 'N':
+ id_to_pass = None
+
+ mapping_ref.update_status(enabled, frozen, id_to_pass)
+
+ def get_license_file(self, name: str) -> st.FileData:
+ return _get_file(self, name, 'L')
+
+ def get_upstream_license_file_url(self, name: str) -> str:
+ return _get_upstream_file_url(self, name, 'L')
+
+ def get_required_mapping(self, identifier: str) \
+ -> 'ConcreteMappingVersionRef':
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ iv2.item_version_id
+ FROM
+ item_versions AS iv1
+ JOIN resolved_required_mappings AS rrm
+ ON iv1.item_version_id =
+ rrm.requiring_mapping_id
+ JOIN item_versions AS iv2
+ ON rrm.required_mapping_id =
+ iv2.item_version_id
+ JOIN items AS i
+ ON iv2.item_id = i.item_id
+ WHERE
+ iv1.item_version_id = ? AND
+ i.identifier = ?;
+ ''',
+ (self.id, identifier)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (required_id,), = rows
+
+ return ConcreteMappingVersionRef(str(required_id), self.state)
+
+ def get_payload_resource(self, pattern: str, identifier: str) \
+ -> 'ConcreteResourceVersionRef':
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ iv.item_version_id
+ FROM
+ payloads AS p
+ JOIN resolved_depended_resources AS rdd
+ USING(payload_id)
+ JOIN item_versions AS iv
+ ON rdd.resource_item_id = iv.item_version_id
+ JOIN items AS i
+ USING (item_id)
+ WHERE
+ (p.mapping_item_id = ? AND p.pattern = ?) AND
+ i.identifier = ?;
+ ''',
+ (self.id, pattern, identifier)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (resource_ver_id,), = rows
+
+ return ConcreteResourceVersionRef(str(resource_ver_id), self.state)
+
+ def get_item_display_info(self) -> st.RichMappingDisplayInfo:
+ with self._mapping_ref() as mapping_ref:
+ return mapping_ref.get_display_info()
+
+
+@dc.dataclass(frozen=True)
+class ConcreteMappingVersionStore(st.MappingVersionStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.MappingVersionRef:
+ return ConcreteMappingVersionRef(str(int(id)), self.state)
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteResourceRef(st.ResourceRef):
+ state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
+
+ def get_display_info(self) -> st.RichResourceDisplayInfo:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ 'SELECT identifier FROM items WHERE item_id = ?;',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (identifier,), = rows
+
+ cursor.execute(
+ '''
+ SELECT
+ item_version_id,
+ definition,
+ repo,
+ repo_iteration,
+ installed,
+ active,
+ is_orphan,
+ is_local
+ FROM
+ item_versions_extra
+ WHERE
+ item_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ version_infos = []
+
+ for (item_version_id, definition, repo, repo_iteration,
+ installed_status, active_status, is_orphan, is_local) in rows:
+ ref = ConcreteResourceVersionRef(str(item_version_id), self.state)
+
+ item_info = item_infos.ResourceInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
+
+ display_info = st.ResourceVersionDisplayInfo(
+ ref = ref,
+ info = item_info,
+ installed = st.InstalledStatus(installed_status),
+ active = st.ActiveStatus(active_status),
+ is_orphan = is_orphan,
+ is_local = is_local
+ )
+
+ version_infos.append(display_info)
+
+ return st.RichResourceDisplayInfo(
+ ref = self,
+ identifier = identifier,
+ all_versions = sorted(version_infos, key=(lambda vi: vi.info))
+ )
+
+
+@dc.dataclass(frozen=True)
+class ConcreteResourceStore(st.ResourceStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.ResourceRef:
+ return ConcreteResourceRef(str(int(id)), self.state)
+
+ def get_display_infos(self) -> t.Sequence[st.ResourceDisplayInfo]:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ "SELECT item_id, identifier FROM items WHERE type = 'R';"
+ )
+
+ rows = cursor.fetchall()
+
+ result = []
+
+ for item_id, identifier in rows:
+ ref = ConcreteResourceRef(str(item_id), self.state)
+
+ result.append(st.ResourceDisplayInfo(ref, identifier))
+
+ return sorted(result, key=(lambda di: di.identifier))
+
+ def get_by_identifier(self, identifier: str) -> st.ResourceRef:
+ with self.state.cursor() as cursor:
+ item_id = _get_item_id(cursor, 'R', identifier)
+
+ return ConcreteResourceRef(item_id, self.state)
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteResourceVersionRef(st.ResourceVersionRef):
+ state: base.HaketiloStateWithFields
+
+ def install(self) -> None:
+ return _install_version(self)
+
+ def uninstall(self) -> t.Optional['ConcreteResourceVersionRef']:
+ return _uninstall_version(self)
+
+ def get_license_file(self, name: str) -> st.FileData:
+ return _get_file(self, name, 'L')
+
+ def get_resource_file(self, name: str) -> st.FileData:
+ return _get_file(self, name, 'W')
+
+ def get_upstream_license_file_url(self, name: str) -> str:
+ return _get_upstream_file_url(self, name, 'L')
+
+ def get_upstream_resource_file_url(self, name: str) -> str:
+ return _get_upstream_file_url(self, name, 'W')
+
+ def get_dependency(self, identifier: str) -> st.ResourceVersionRef:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ iv.item_version_id
+ FROM
+ resolved_depended_resources AS rdd1
+ JOIN payloads AS p
+ ON rdd1.payload_id = p.payload_id
+ JOIN resolved_depended_resources AS rdd2
+ ON p.payload_id = rdd2.payload_id
+ JOIN item_versions AS iv
+ ON rdd2.resource_item_id = iv.item_version_id
+ JOIN items AS i
+ USING (item_id)
+ WHERE
+ rdd1.resource_item_id = ? AND i.identifier = ?;
+ ''',
+ (self.id, identifier)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (dep_id,), = rows
+
+ return ConcreteResourceVersionRef(str(dep_id), self.state)
+
+ def get_item_display_info(self) -> st.RichResourceDisplayInfo:
+ with self.state.cursor() as cursor:
+ resource_id = _get_parent_item_id(cursor, self.id)
+ resource_ref = ConcreteResourceRef(resource_id, self.state)
+ return resource_ref.get_display_info()
+
+
+@dc.dataclass(frozen=True)
+class ConcreteResourceVersionStore(st.ResourceVersionStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.ResourceVersionRef:
+ return ConcreteResourceVersionRef(str(int(id)), self.state)
diff --git a/src/hydrilla/proxy/state_impl/payloads.py b/src/hydrilla/proxy/state_impl/payloads.py
new file mode 100644
index 0000000..383217c
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/payloads.py
@@ -0,0 +1,272 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (PayloadRef subtype).
+#
+# 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 provides an interface to interact with payloads inside Haketilo.
+"""
+
+import sqlite3
+import dataclasses as dc
+import typing as t
+
+from ... import item_infos
+from .. import state as st
+from . import base
+from . import items
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcretePayloadRef(st.PayloadRef):
+ state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
+
+ def get_data(self) -> st.PayloadData:
+ try:
+ return self.state.payloads_data[self]
+ except KeyError:
+ raise st.MissingItemError()
+
+ def has_problems(self) -> bool:
+ with self.state.cursor(transaction=True) as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ iv.installed == 'F'
+ FROM
+ payloads AS p
+ JOIN item_versions AS iv
+ ON p.mapping_item_id = iv.item_version_id
+ WHERE
+ p.payload_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (mapping_install_failed,), = rows
+ if mapping_install_failed:
+ return True
+
+ cursor.execute(
+ '''
+ SELECT
+ COUNT(*) > 0
+ FROM
+ payloads AS p
+ JOIN resolved_depended_resources AS rdd
+ USING (payload_id)
+ JOIN item_versions AS iv
+ ON rdd.resource_item_id = iv.item_version_id
+ WHERE
+ p.payload_id = ? AND iv.installed = 'F';
+ ''',
+ (self.id,)
+ )
+
+ (resource_install_failed,), = cursor.fetchall()
+ if resource_install_failed:
+ return True
+
+ return False
+
+ def get_display_info(self) -> st.PayloadDisplayInfo:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ p.pattern,
+ ive.item_version_id,
+ ive.definition,
+ ive.repo,
+ ive.repo_iteration,
+ ive.installed,
+ ive.active,
+ ive.is_orphan,
+ ive.is_local
+ FROM
+ payloads AS p
+ JOIN item_versions_extra AS ive
+ ON p.mapping_item_id = ive.item_version_id
+ WHERE
+ p.payload_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (pattern_str, mapping_version_id, definition, repo, repo_iteration,
+ installed_status, active_status, is_orphan, is_local), = rows
+
+ has_problems = self.has_problems()
+
+ mapping_version_ref = items.ConcreteMappingVersionRef(
+ id = str(mapping_version_id),
+ state = self.state
+ )
+
+ mapping_version_info = item_infos.MappingInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
+
+ mapping_version_display_info = st.MappingVersionDisplayInfo(
+ ref = mapping_version_ref,
+ info = mapping_version_info,
+ installed = st.InstalledStatus(installed_status),
+ active = st.ActiveStatus(active_status),
+ is_orphan = is_orphan,
+ is_local = is_local
+ )
+
+ return st.PayloadDisplayInfo(
+ ref = self,
+ mapping_info = mapping_version_display_info,
+ pattern = pattern_str,
+ has_problems = has_problems
+ )
+
+ def ensure_items_installed(self) -> None:
+ with self.state.cursor(transaction=True) as cursor:
+ cursor.execute(
+ 'SELECT mapping_item_id FROM payloads WHERE payload_id = ?;',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (mapping_version_id,), = rows
+
+ mapping_version_ref = items.ConcreteMappingVersionRef(
+ id = str(mapping_version_id),
+ state = self.state
+ )
+
+ mapping_version_ref.ensure_depended_items_installed()
+
+ def get_script_paths(self) \
+ -> t.Iterable[t.Sequence[str]]:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ i.identifier, fu.name
+ FROM
+ payloads AS p
+ LEFT JOIN resolved_depended_resources AS rdd
+ USING (payload_id)
+ LEFT JOIN item_versions AS iv
+ ON rdd.resource_item_id = iv.item_version_id
+ LEFT JOIN items AS i
+ USING (item_id)
+ LEFT JOIN file_uses AS fu
+ USING (item_version_id)
+ WHERE
+ fu.type = 'W' AND
+ p.payload_id = ? AND
+ (fu.idx IS NOT NULL OR rdd.idx IS NULL)
+ ORDER BY
+ rdd.idx, fu.idx;
+ ''',
+ (self.id,)
+ )
+
+ paths: list[t.Sequence[str]] = []
+ for resource_identifier, file_name in cursor.fetchall():
+ if resource_identifier is None:
+ # payload found but it had no script files
+ return ()
+
+ paths.append((resource_identifier, *file_name.split('/')))
+
+ if paths == []:
+ # payload not found
+ raise st.MissingItemError()
+
+ return paths
+
+ def get_file_data(self, path: t.Sequence[str]) \
+ -> t.Optional[st.FileData]:
+ if len(path) == 0:
+ raise st.MissingItemError()
+
+ resource_identifier, *file_name_segments = path
+
+ file_name = '/'.join(file_name_segments)
+
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ f.data, fu.mime_type
+ FROM
+ payloads AS p
+ JOIN resolved_depended_resources AS rdd
+ USING (payload_id)
+ JOIN item_versions AS iv
+ ON rdd.resource_item_id = iv.item_version_id
+ JOIN items AS i
+ USING (item_id)
+ JOIN file_uses AS fu
+ USING (item_version_id)
+ JOIN files AS f
+ USING (file_id)
+ WHERE
+ p.payload_id = ? AND
+ i.identifier = ? AND
+ fu.name = ? AND
+ fu.type = 'W';
+ ''',
+ (self.id, resource_identifier, file_name)
+ )
+
+ result = cursor.fetchall()
+
+ if result == []:
+ return None
+
+ (data, mime_type), = result
+
+ return st.FileData(mime_type=mime_type, name=file_name, contents=data)
+
+
+@dc.dataclass(frozen=True)
+class ConcretePayloadStore(st.PayloadStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.PayloadRef:
+ return ConcretePayloadRef(str(int(id)), self.state)
diff --git a/src/hydrilla/proxy/state_impl/repos.py b/src/hydrilla/proxy/state_impl/repos.py
new file mode 100644
index 0000000..7e38a90
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/repos.py
@@ -0,0 +1,363 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (RepoRef and RepoStore subtypes).
+#
+# 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 provides an interface to interact with repositories configured
+inside Haketilo.
+"""
+
+import re
+import json
+import tempfile
+import sqlite3
+import typing as t
+import dataclasses as dc
+
+from urllib.parse import urlparse, urljoin
+from datetime import datetime
+from pathlib import Path
+
+import requests
+
+from ... import json_instances
+from ... import item_infos
+from ... import versions
+from .. import state as st
+from .. import simple_dependency_satisfying as sds
+from . import base
+
+
+repo_name_regex = re.compile(r'''
+^
+(?:
+ []a-zA-Z0-9()<>^&$.!,?@#|;:%"'*{}[/_=+-]+ # allowed non-whitespace characters
+
+ (?: # optional additional words separated by single spaces
+ [ ]
+ []a-zA-Z0-9()<>^&$.!,?@#|;:%"'*{}[/_=+-]+
+ )*
+)
+$
+''', re.VERBOSE)
+
+def sanitize_repo_name(name: str) -> str:
+ name = name.strip()
+
+ if repo_name_regex.match(name) is None:
+ raise st.RepoNameInvalid()
+
+ return name
+
+
+def sanitize_repo_url(url: str) -> str:
+ try:
+ parsed = urlparse(url)
+ except:
+ raise st.RepoUrlInvalid()
+
+ if parsed.scheme not in ('http', 'https'):
+ raise st.RepoUrlInvalid()
+
+ if url[-1] != '/':
+ url = url + '/'
+
+ return url
+
+
+def ensure_repo_not_deleted(cursor: sqlite3.Cursor, repo_id: str) -> None:
+ cursor.execute(
+ 'SELECT deleted FROM repos WHERE repo_id = ?;',
+ (repo_id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (deleted,), = rows
+
+ if deleted:
+ raise st.MissingItemError()
+
+
+def sync_remote_repo_definitions(repo_url: str, dest: Path) -> None:
+ try:
+ list_all_response = requests.get(urljoin(repo_url, 'list_all'))
+ assert list_all_response.ok
+
+ list_instance = list_all_response.json()
+ except:
+ raise st.RepoCommunicationError()
+
+ try:
+ json_instances.validate_instance(
+ list_instance,
+ 'api_package_list-{}.schema.json'
+ )
+ except json_instances.UnknownSchemaError:
+ raise st.RepoApiVersionUnsupported()
+ except:
+ raise st.RepoCommunicationError()
+
+ ref: dict[str, t.Any]
+
+ for item_type_name in ('resource', 'mapping'):
+ for ref in list_instance[item_type_name + 's']:
+ ver = versions.version_string(versions.normalize(ref['version']))
+ item_rel_path = f'{item_type_name}/{ref["identifier"]}/{ver}'
+
+ try:
+ item_response = requests.get(urljoin(repo_url, item_rel_path))
+ assert item_response.ok
+ except:
+ raise st.RepoCommunicationError()
+
+ item_path = dest / item_rel_path
+ item_path.parent.mkdir(parents=True, exist_ok=True)
+ item_path.write_bytes(item_response.content)
+
+
+def make_repo_display_info(
+ ref: st.RepoRef,
+ name: str,
+ url: str,
+ deleted: bool,
+ last_refreshed: t.Optional[int],
+ resource_count: int,
+ mapping_count: int
+) -> st.RepoDisplayInfo:
+ last_refreshed_converted: t.Optional[datetime] = None
+ if last_refreshed is not None:
+ last_refreshed_converted = datetime.fromtimestamp(last_refreshed)
+
+ return st.RepoDisplayInfo(
+ ref = ref,
+ is_local_semirepo = ref.id == '1',
+ name = name,
+ url = url,
+ deleted = deleted,
+ last_refreshed = last_refreshed_converted,
+ resource_count = resource_count,
+ mapping_count = mapping_count
+ )
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteRepoRef(st.RepoRef):
+ """...."""
+ state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
+
+ def remove(self) -> None:
+ with self.state.cursor(transaction=True) as cursor:
+ ensure_repo_not_deleted(cursor, self.id)
+
+ cursor.execute(
+ '''
+ UPDATE
+ repos
+ SET
+ deleted = TRUE,
+ url = '',
+ active_iteration_id = NULL,
+ last_refreshed = NULL
+ WHERE
+ repo_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ self.state.soft_prune_orphan_items()
+ self.state.recompute_dependencies()
+
+ def update(
+ self,
+ *,
+ name: t.Optional[str] = None,
+ url: t.Optional[str] = None
+ ) -> None:
+ if name is not None:
+ if name.isspace():
+ raise st.RepoNameInvalid()
+
+ name = sanitize_repo_name(name)
+
+ if url is not None:
+ if url.isspace():
+ raise st.RepoUrlInvalid()
+
+ url = sanitize_repo_url(url)
+
+ if name is None and url is None:
+ return
+
+ with self.state.cursor(transaction=True) as cursor:
+ ensure_repo_not_deleted(cursor, self.id)
+
+ if url is not None:
+ cursor.execute(
+ 'UPDATE repos SET url = ? WHERE repo_id = ?;',
+ (url, self.id)
+ )
+
+ if name is not None:
+ try:
+ cursor.execute(
+ 'UPDATE repos SET name = ? WHERE repo_id = ?;',
+ (name, self.id)
+ )
+ except sqlite3.IntegrityError:
+ raise st.RepoNameTaken()
+
+ self.state.rebuild_structures(rules=False)
+
+ def refresh(self) -> None:
+ with self.state.cursor(transaction=True) as cursor:
+ ensure_repo_not_deleted(cursor, self.id)
+
+ cursor.execute(
+ 'SELECT url FROM repos WHERE repo_id = ?;',
+ (self.id,)
+ )
+
+ (repo_url,), = cursor.fetchall()
+
+ with tempfile.TemporaryDirectory() as tmpdir_str:
+ tmpdir = Path(tmpdir_str)
+ sync_remote_repo_definitions(repo_url, tmpdir)
+ self.state.import_items(tmpdir, int(self.id))
+
+ def get_display_info(self) -> st.RepoDisplayInfo:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ name, url, deleted, last_refreshed,
+ resource_count, mapping_count
+ FROM
+ repo_display_infos
+ WHERE
+ repo_id = ?;
+ ''',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ row, = rows
+
+ return make_repo_display_info(self, *row)
+
+
+@dc.dataclass(frozen=True)
+class ConcreteRepoStore(st.RepoStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.RepoRef:
+ return ConcreteRepoRef(str(int(id)), self.state)
+
+ def add(self, name: str, url: str) -> st.RepoRef:
+ name = name.strip()
+ if repo_name_regex.match(name) is None:
+ raise st.RepoNameInvalid()
+
+ url = sanitize_repo_url(url)
+
+ with self.state.cursor(transaction=True) as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ COUNT(repo_id)
+ FROM
+ repos
+ WHERE
+ NOT deleted AND name = ?;
+ ''',
+ (name,)
+ )
+ (name_taken,), = cursor.fetchall()
+
+ if name_taken:
+ raise st.RepoNameTaken()
+
+ cursor.execute(
+ '''
+ INSERT INTO repos(name, url)
+ VALUES (?, ?)
+ ON CONFLICT (name)
+ DO UPDATE SET
+ name = excluded.name,
+ url = excluded.url,
+ deleted = FALSE,
+ last_refreshed = NULL;
+ ''',
+ (name, url)
+ )
+
+ cursor.execute('SELECT repo_id FROM repos WHERE name = ?;', (name,))
+
+ (repo_id,), = cursor.fetchall()
+
+ return ConcreteRepoRef(str(repo_id), self.state)
+
+ def get_display_infos(self, include_deleted: bool = False) \
+ -> t.Sequence[st.RepoDisplayInfo]:
+ with self.state.cursor() as cursor:
+ condition: str = 'TRUE'
+ if include_deleted:
+ condition = 'COALESCE(deleted = FALSE, TRUE)'
+
+ cursor.execute(
+ f'''
+ SELECT
+ repo_id, name, url, deleted, last_refreshed,
+ resource_count, mapping_count
+ FROM
+ repo_display_infos
+ WHERE
+ {condition}
+ ORDER BY
+ repo_id != 1, name;
+ '''
+ )
+
+ all_rows = cursor.fetchall()
+
+ assert len(all_rows) > 0 and all_rows[0][0] == 1
+
+ result = []
+ for row in all_rows:
+ repo_id, *rest = row
+
+ ref = ConcreteRepoRef(str(repo_id), self.state)
+
+ result.append(make_repo_display_info(ref, *rest))
+
+ return result
diff --git a/src/hydrilla/proxy/state_impl/rules.py b/src/hydrilla/proxy/state_impl/rules.py
new file mode 100644
index 0000000..1761b04
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/rules.py
@@ -0,0 +1,196 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Haketilo proxy data and configuration (RuleRef and RuleStore subtypes).
+#
+# 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 provides an interface to interact with script allowing/blocking
+rules configured inside Haketilo.
+"""
+
+import sqlite3
+import typing as t
+import dataclasses as dc
+
+from ... import url_patterns
+from .. import state as st
+from . import base
+
+
+def ensure_rule_not_deleted(cursor: sqlite3.Cursor, rule_id: str) -> None:
+ cursor.execute('SELECT COUNT(*) from rules where rule_id = ?;', (rule_id,))
+
+ (rule_present,), = cursor.fetchall()
+
+ if not rule_present:
+ raise st.MissingItemError()
+
+def sanitize_rule_pattern(pattern: str) -> str:
+ pattern = pattern.strip()
+
+ try:
+ assert pattern
+ return url_patterns.normalize_pattern(pattern)
+ except:
+ raise st.RulePatternInvalid()
+
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteRuleRef(st.RuleRef):
+ state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
+
+ def remove(self) -> None:
+ with self.state.cursor(transaction=True) as cursor:
+ ensure_rule_not_deleted(cursor, self.id)
+
+ cursor.execute('DELETE FROM rules WHERE rule_id = ?;', self.id)
+
+ self.state.rebuild_structures(payloads=False)
+
+ def update(
+ self,
+ *,
+ pattern: t.Optional[str] = None,
+ allow: t.Optional[bool] = None
+ ) -> None:
+ if pattern is not None:
+ pattern = sanitize_rule_pattern(pattern)
+
+ if pattern is None and allow is None:
+ return
+
+ with self.state.cursor(transaction=True) as cursor:
+ ensure_rule_not_deleted(cursor, self.id)
+
+ if allow is not None:
+ cursor.execute(
+ 'UPDATE rules SET allow_scripts = ? WHERE rule_id = ?;',
+ (allow, self.id)
+ )
+
+ if pattern is not None:
+ cursor.execute(
+ 'DELETE FROM rules WHERE pattern = ? AND rule_id != ?;',
+ (pattern, self.id)
+ )
+
+ cursor.execute(
+ 'UPDATE rules SET pattern = ? WHERE rule_id = ?;',
+ (pattern, self.id)
+ )
+
+ self.state.rebuild_structures(payloads=False)
+
+ def get_display_info(self) -> st.RuleDisplayInfo:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ 'SELECT pattern, allow_scripts FROM rules WHERE rule_id = ?;',
+ (self.id,)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (pattern, allow), = rows
+
+ return st.RuleDisplayInfo(self, pattern, allow)
+
+
+@dc.dataclass(frozen=True)
+class ConcreteRuleStore(st.RuleStore):
+ state: base.HaketiloStateWithFields
+
+ def get(self, id: str) -> st.RuleRef:
+ return ConcreteRuleRef(str(int(id)), self.state)
+
+ def add(self, pattern: str, allow: bool) -> st.RuleRef:
+ pattern = sanitize_rule_pattern(pattern)
+
+ with self.state.cursor(transaction=True) as cursor:
+ cursor.execute(
+ '''
+ INSERT INTO rules(pattern, allow_scripts)
+ VALUES (?, ?)
+ ON CONFLICT (pattern)
+ DO UPDATE SET allow_scripts = excluded.allow_scripts;
+ ''',
+ (pattern, allow)
+ )
+
+ cursor.execute(
+ 'SELECT rule_id FROM rules WHERE pattern = ?;',
+ (pattern,)
+ )
+
+ (rule_id,), = cursor.fetchall()
+
+ self.state.rebuild_structures(payloads=False)
+
+ return ConcreteRuleRef(str(rule_id), self.state)
+
+ def get_display_infos(self, allow: t.Optional[bool] = None) \
+ -> t.Sequence[st.RuleDisplayInfo]:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT
+ rule_id, pattern, allow_scripts
+ FROM
+ rules
+ WHERE
+ COALESCE(allow_scripts = ?, TRUE)
+ ORDER BY
+ pattern;
+ ''',
+ (allow,)
+ )
+
+ rows = cursor.fetchall()
+
+ result = []
+ for rule_id, pattern, allow_scripts in rows:
+ ref = ConcreteRuleRef(str(rule_id), self.state)
+
+ result.append(st.RuleDisplayInfo(ref, pattern, allow_scripts))
+
+ return result
+
+ def get_by_pattern(self, pattern: str) -> st.RuleRef:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ 'SELECT rule_id FROM rules WHERE pattern = ?;',
+ (url_patterns.normalize_pattern(pattern),)
+ )
+
+ rows = cursor.fetchall()
+
+ if rows == []:
+ raise st.MissingItemError()
+
+ (rule_id,), = rows
+
+ return ConcreteRuleRef(str(rule_id), self.state)
diff --git a/src/hydrilla/proxy/state_impl/tables.sql b/src/hydrilla/proxy/state_impl/tables.sql
new file mode 100644
index 0000000..504d023
--- /dev/null
+++ b/src/hydrilla/proxy/state_impl/tables.sql
@@ -0,0 +1,334 @@
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+-- SQLite tables definitions for Haketilo proxy.
+--
+-- 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.
+
+BEGIN TRANSACTION;
+
+CREATE TABLE general(
+ haketilo_version VARCHAR NOT NULL,
+ default_allow_scripts BOOLEAN NOT NULL,
+ advanced_user BOOLEAN NOT NULL,
+ repo_refresh_seconds INTEGER NOT NULL,
+ -- "mapping_use_mode" determines whether current mode is AUTO,
+ -- WHEN_ENABLED or QUESTION.
+ mapping_use_mode CHAR(1) NOT NULL,
+
+ CHECK (rowid = 1),
+ CHECK (mapping_use_mode IN ('A', 'W', 'Q')),
+ CHECK (haketilo_version = '3.0b1')
+);
+
+INSERT INTO general(
+ rowid,
+ haketilo_version,
+ default_allow_scripts,
+ advanced_user,
+ repo_refresh_seconds,
+ mapping_use_mode
+)
+VALUES(
+ 1,
+ '3.0b1',
+ FALSE,
+ FALSE,
+ 24 * 60 * 60,
+ 'Q'
+);
+
+CREATE TABLE rules(
+ rule_id INTEGER PRIMARY KEY,
+
+ pattern VARCHAR NOT NULL,
+ allow_scripts BOOLEAN NOT NULL,
+
+ UNIQUE (pattern)
+);
+
+CREATE TABLE repos(
+ repo_id INTEGER PRIMARY KEY,
+
+ name VARCHAR NOT NULL,
+ url VARCHAR NOT NULL,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ next_iteration INTEGER NOT NULL DEFAULT 1,
+ active_iteration_id INTEGER NULL,
+ last_refreshed INTEGER NULL,
+
+ UNIQUE (name),
+ -- The local semi-repo used for packages installed offline is always
+ -- marked as deleted. Semi-repo's name is chosen as an empty string so
+ -- as not to collide with other names (which are required to be
+ -- non-empty).
+ CHECK ((repo_id = 1) = (name = '')),
+ CHECK (repo_id != 1 OR deleted = TRUE),
+ -- All deleted repos shall have "url" set to an empty string. All other
+ -- repos shall have a valid http(s) URL.
+ CHECK (deleted = (url = '')),
+ -- Only non-deleted repos are allowed to have an active iteration.
+ CHECK (NOT deleted OR active_iteration_id IS NULL),
+ -- Only non-deleted repos are allowed to have last refresh timestamp.
+ CHECK (NOT deleted OR last_refreshed IS NULL),
+
+ FOREIGN KEY (active_iteration_id)
+ REFERENCES repo_iterations(repo_iteration_id)
+ ON DELETE SET NULL
+);
+
+INSERT INTO repos(repo_id, name, url, deleted)
+VALUES(1, '', '', TRUE);
+
+INSERT INTO repos(name, url)
+VALUES('Hydrilla official', 'https://hydrilla.koszko.org/api_v2/');
+
+CREATE TABLE repo_iterations(
+ repo_iteration_id INTEGER PRIMARY KEY,
+
+ repo_id INTEGER NOT NULL,
+ iteration INTEGER NOT NULL,
+
+ UNIQUE (repo_id, iteration),
+
+ FOREIGN KEY (repo_id)
+ REFERENCES repos (repo_id)
+);
+
+CREATE VIEW orphan_iterations
+AS
+SELECT
+ ri.repo_iteration_id,
+ ri.repo_id,
+ ri.iteration
+FROM
+ repo_iterations AS ri
+ JOIN repos AS r USING (repo_id)
+WHERE
+ COALESCE(r.active_iteration_id != ri.repo_iteration_id, TRUE);
+
+CREATE TABLE items(
+ item_id INTEGER PRIMARY KEY,
+
+ -- "type" determines whether it's resource or mapping.
+ type CHAR(1) NOT NULL,
+ identifier VARCHAR NOT NULL,
+
+ UNIQUE (type, identifier),
+ CHECK (type IN ('R', 'M'))
+);
+
+CREATE TABLE mapping_statuses(
+ -- The item with this id shall be a mapping ("type" = 'M'). For each
+ -- mapping row in "items" there must be an accompanying row in this
+ -- table.
+ item_id INTEGER PRIMARY KEY,
+
+ -- "enabled" determines whether mapping's status is ENABLED,
+ -- DISABLED or NO_MARK.
+ enabled CHAR(1) NOT NULL DEFAULT 'N',
+ -- "frozen" determines whether an enabled mapping is to be kept in its
+ -- EXACT_VERSION, is to be updated only with versions from the same
+ -- REPOSITORY or is NOT_FROZEN at all.
+ frozen CHAR(1) NULL,
+ -- Only one version of a mapping is allowed to be active at any time.
+ -- "active_version_id" indicates which version it is. Only a mapping
+ -- version referenced by "active_version_id" is allowed to have rows
+ -- in the "payloads" table reference it.
+ -- "active_version_id" shall be updated every time dependency tree is
+ -- recomputed.
+ active_version_id INTEGER NULL,
+
+ CHECK (enabled IN ('E', 'D', 'N')),
+ CHECK ((frozen IS NULL) = (enabled != 'E')),
+ CHECK (frozen IS NULL OR frozen in ('E', 'R', 'N')),
+
+ FOREIGN KEY (item_id)
+ REFERENCES items (item_id)
+ ON DELETE CASCADE,
+ -- We'd like to set "active_version_id" to NULL when referenced entry is
+ -- deleted, but we cannot do it with ON DELETE clause because the
+ -- foreign key is composite. For now - this will be done by the
+ -- application.
+ FOREIGN KEY (active_version_id, item_id)
+ REFERENCES item_versions (item_version_id, item_id)
+);
+
+CREATE TABLE item_versions(
+ item_version_id INTEGER PRIMARY KEY,
+
+ item_id INTEGER NOT NULL,
+ version VARCHAR NOT NULL,
+ -- "installed" determines whether item is INSTALLED, is NOT_INSTALLED or
+ -- it FAILED_TO_INSTALL when last tried. If "required" in a row of
+ -- "mapping_statuses is set to TRUE, the mapping version and all
+ -- resource versions corresponding to it are supposed to have
+ -- "installed" set to 'I'.
+ installed CHAR(1) NOT NULL,
+ repo_iteration_id INTEGER NOT NULL,
+ definition BLOB NOT NULL,
+ definition_sha256 CHAR(64) NOT NULL,
+ -- "active" determines whether a version of this mapping is active
+ -- because it is REQUIRED, has been AUTO activated or is NOT_ACTIVE.
+ -- "active" shall be updated every time dependency tree is recomputed.
+ -- It shall be set to NOT_ACTIVE if and only if given row does not
+ -- correspond to "active_version_id" of any row in "mapping_statuses".
+ active CHAR(1) NOT NULL DEFAULT 'N',
+
+ UNIQUE (item_id, version, repo_iteration_id),
+ -- Constraint below needed to allow foreign key from "mapping_statuses".
+ UNIQUE (item_version_id, item_id),
+ CHECK (installed in ('I', 'N', 'F')),
+ CHECK (active in ('R', 'A', 'N')),
+
+ FOREIGN KEY (item_id)
+ REFERENCES items (item_id),
+ FOREIGN KEY (repo_iteration_id)
+ REFERENCES repo_iterations (repo_iteration_id)
+);
+
+CREATE VIEW repo_display_infos
+AS
+SELECT
+ r.repo_id, r.name, r.url, r.deleted, r.last_refreshed,
+ COALESCE(SUM(i.type = 'R'), 0) AS resource_count,
+ COALESCE(SUM(i.type = 'M'), 0) AS mapping_count
+FROM
+ repos AS r
+ LEFT JOIN repo_iterations AS ir USING (repo_id)
+ LEFT JOIN item_versions AS iv USING (repo_iteration_id)
+ LEFT JOIN items AS i USING (item_id)
+GROUP BY
+ r.repo_id, r.name, r.url, r.deleted, r.last_refreshed;
+
+-- Every time a repository gets refreshed or a mapping gets enabled/disabled,
+-- the dependency tree is recomputed. In the process the "payloads" table gets
+-- cleare and repopulated together with the "resolved_depended_resources" that
+-- depends on it.
+CREATE TABLE payloads(
+ payload_id INTEGER PRIMARY KEY,
+
+ mapping_item_id INTEGER NOT NULL,
+ pattern VARCHAR NOT NULL,
+ -- What privileges should be granted on pages where this
+ -- resource/mapping is used.
+ eval_allowed BOOLEAN NOT NULL,
+ cors_bypass_allowed BOOLEAN NOT NULL,
+
+ UNIQUE (mapping_item_id, pattern),
+
+ FOREIGN KEY (mapping_item_id)
+ REFERENCES item_versions (item_version_id)
+ ON DELETE CASCADE
+);
+
+CREATE VIEW item_versions_extra
+AS
+SELECT
+ iv.item_version_id,
+ iv.item_id,
+ iv.version,
+ iv.installed,
+ iv.repo_iteration_id,
+ iv.definition,
+ iv.active,
+ r.repo_id, r.name AS repo,
+ ri.repo_iteration_id, ri.iteration AS repo_iteration,
+ COALESCE(r.active_iteration_id, -1) != ri.repo_iteration_id AND r.repo_id != 1
+ AS is_orphan,
+ r.repo_id = 1 AS is_local
+FROM
+ item_versions AS iv
+ JOIN repo_iterations AS ri USING (repo_iteration_id)
+ JOIN repos AS r USING (repo_id);
+
+CREATE TABLE resolved_depended_resources(
+ payload_id INTEGER,
+ resource_item_id INTEGER,
+
+ -- "idx" determines the ordering of resources.
+ idx INTEGER,
+
+ PRIMARY KEY (payload_id, resource_item_id),
+
+ FOREIGN KEY (payload_id)
+ REFERENCES payloads (payload_id)
+ ON DELETE CASCADE,
+ FOREIGN KEY (resource_item_id)
+ REFERENCES item_versions (item_version_id)
+ ON DELETE CASCADE
+) WITHOUT ROWID;
+
+CREATE TABLE resolved_required_mappings(
+ requiring_mapping_id INTEGER,
+ required_mapping_id INTEGER,
+
+ PRIMARY KEY (requiring_mapping_id, required_mapping_id),
+
+ FOREIGN KEY (requiring_mapping_id)
+ REFERENCES item_versions (item_version_id)
+ ON DELETE CASCADE,
+ FOREIGN KEY (required_mapping_id)
+ REFERENCES item_versions (item_version_id)
+ ON DELETE CASCADE
+) WITHOUT ROWID;
+
+CREATE TABLE files(
+ file_id INTEGER PRIMARY KEY,
+
+ -- File's hash as hexadecimal string.
+ sha256 CHAR(64) NOT NULL,
+ -- The value of "data" - if not NULL - shall be a bytes sequence that
+ -- corresponds the hash stored in "sha256".
+ data BLOB NULL,
+
+ UNIQUE (sha256)
+);
+
+CREATE TABLE file_uses(
+ file_use_id INTEGER PRIMARY KEY,
+
+ -- If item version referenced by "item_version_id" has "installed" set
+ -- to 'I', the file referenced by "file_id" is supposed to have "data"
+ -- set to a valid, non-NULL value.
+ item_version_id INTEGER NOT NULL,
+ file_id INTEGER NOT NULL,
+ name VARCHAR NOT NULL,
+ -- "type" determines whether it's license file or web resource.
+ type CHAR(1) NOT NULL,
+ mime_type VARCHAR NOT NULL,
+ -- "idx" determines the ordering of item's files of given type.
+ idx INTEGER NOT NULL,
+
+ CHECK (type IN ('L', 'W')),
+ UNIQUE(item_version_id, type, idx),
+ UNIQUE(item_version_id, type, name),
+
+ FOREIGN KEY (item_version_id)
+ REFERENCES item_versions(item_version_id)
+ ON DELETE CASCADE,
+ FOREIGN KEY (file_id)
+ REFERENCES files(file_id)
+);
+
+COMMIT TRANSACTION;
diff --git a/src/hydrilla/proxy/web_ui/__init__.py b/src/hydrilla/proxy/web_ui/__init__.py
new file mode 100644
index 0000000..1ae5dba
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# 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
new file mode 100644
index 0000000..f54f72e
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/_app.py
@@ -0,0 +1,29 @@
+# SPDX-License-Identifier: CC0-1.0
+
+# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+#
+# Available under the terms of Creative Commons Zero v1.0 Universal.
+
+import enum
+import dataclasses as dc
+import typing as t
+
+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_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/items.py b/src/hydrilla/proxy/web_ui/items.py
new file mode 100644
index 0000000..d0f0f2e
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/items.py
@@ -0,0 +1,440 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Proxy web UI package/library management.
+#
+# 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.
+
+"""
+.....
+"""
+
+import typing as t
+
+from urllib.parse import unquote
+
+import flask
+import werkzeug
+
+from ... import item_infos
+from .. import state as st
+from . import _app
+
+
+bp = flask.Blueprint('items', __package__)
+
+@bp.route('/packages')
+def packages() -> werkzeug.Response:
+ store = _app.get_haketilo_state().mapping_store()
+
+ html = flask.render_template(
+ 'items/packages.html.jinja',
+ display_infos = store.get_display_infos()
+ )
+ return flask.make_response(html, 200)
+
+@bp.route('/libraries')
+def libraries() -> werkzeug.Response:
+ store = _app.get_haketilo_state().resource_store()
+
+ html = flask.render_template(
+ 'items/libraries.html.jinja',
+ display_infos = store.get_display_infos()
+ )
+ return flask.make_response(html, 200)
+
+def item_store(state: st.HaketiloState, item_type: item_infos.ItemType) \
+ -> t.Union[st.MappingStore, st.ResourceStore]:
+ if item_type == item_infos.ItemType.RESOURCE:
+ return state.resource_store()
+ else:
+ return state.mapping_store()
+
+def show_item(
+ item_id: str,
+ item_type: item_infos.ItemType,
+ errors: t.Mapping[str, bool] = {}
+) -> werkzeug.Response:
+ try:
+ store = item_store(_app.get_haketilo_state(), item_type)
+ display_info = store.get(str(item_id)).get_display_info()
+
+ html = flask.render_template(
+ f'items/{item_type.alt_name}_view.html.jinja',
+ display_info = display_info,
+ **errors
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+
+@bp.route('/libraries/view/<string:item_id>')
+def show_library(item_id: str) -> werkzeug.Response:
+ return show_item(item_id, item_infos.ItemType.RESOURCE)
+
+@bp.route('/packages/view/<string:item_id>')
+def show_package(item_id: str) -> werkzeug.Response:
+ return show_item(item_id, item_infos.ItemType.MAPPING)
+
+def alter_item(item_id: str, item_type: item_infos.ItemType) \
+ -> werkzeug.Response:
+ form_data = flask.request.form
+ action = form_data['action']
+
+ try:
+ store = item_store(_app.get_haketilo_state(), item_type)
+ item_ref = store.get(item_id)
+
+ if action == 'disable_item':
+ assert isinstance(item_ref, st.MappingRef)
+ item_ref.update_status(st.EnabledStatus.DISABLED)
+ elif action == 'unenable_item':
+ assert isinstance(item_ref, st.MappingRef)
+ item_ref.update_status(st.EnabledStatus.NO_MARK)
+ elif action in ('enable_item', 'unfreeze_item'):
+ assert isinstance(item_ref, st.MappingRef)
+ item_ref.update_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.NOT_FROZEN,
+ )
+ elif action == 'freeze_to_repo':
+ assert isinstance(item_ref, st.MappingRef)
+ item_ref.update_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.REPOSITORY,
+ )
+ elif action == 'freeze_to_version':
+ assert isinstance(item_ref, st.MappingRef)
+ item_ref.update_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.EXACT_VERSION,
+ )
+ else:
+ raise ValueError()
+ except st.RepoCommunicationError:
+ return show_item(item_id, item_type, {'repo_communication_error': True})
+ except st.FileInstallationError:
+ return show_item(item_id, item_type, {'file_installation_error': True})
+ except st.ImpossibleSituation:
+ errors = {'impossible_situation_error': True}
+ return show_item(item_id, item_type, errors)
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(
+ flask.url_for(f'.show_{item_type.alt_name}', item_id=item_id)
+ )
+
+@bp.route('/libraries/view/<string:item_id>', methods=['POST'])
+def alter_library(item_id: str) -> werkzeug.Response:
+ return alter_item(item_id, item_infos.ItemType.RESOURCE)
+
+@bp.route('/packages/view/<string:item_id>', methods=['POST'])
+def alter_package(item_id: str) -> werkzeug.Response:
+ return alter_item(item_id, item_infos.ItemType.MAPPING)
+
+
+ItemVersionDisplayInfo = t.Union[
+ st.MappingVersionDisplayInfo,
+ st.ResourceVersionDisplayInfo
+]
+
+def item_version_store(
+ state: st.HaketiloState,
+ item_type: item_infos.ItemType
+) -> t.Union[st.MappingVersionStore, st.ResourceVersionStore]:
+ if item_type == item_infos.ItemType.RESOURCE:
+ return state.resource_version_store()
+ else:
+ return state.mapping_version_store()
+
+def show_item_version(
+ item_version_id: str,
+ item_type: item_infos.ItemType,
+ errors: t.Mapping[str, bool] = {}
+) -> werkzeug.Response:
+ state = _app.get_haketilo_state()
+
+ try:
+ store = item_version_store(state, item_type)
+ version_ref = store.get(item_version_id)
+ display_info = version_ref.get_item_display_info()
+
+ this_info: t.Optional[ItemVersionDisplayInfo] = None
+
+ for info in display_info.all_versions:
+ if info.ref == version_ref:
+ this_info = info
+
+ assert this_info is not None
+
+ html = flask.render_template(
+ f'items/{item_type.alt_name}_viewversion.html.jinja',
+ display_info = display_info,
+ version_display_info = this_info,
+ **errors
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/libraries/viewversion/<string:item_version_id>')
+def show_library_version(item_version_id: str) -> werkzeug.Response:
+ return show_item_version(item_version_id, item_infos.ItemType.RESOURCE)
+
+@bp.route('/packages/viewversion/<string:item_version_id>')
+def show_package_version(item_version_id: str) -> werkzeug.Response:
+ return show_item_version(item_version_id, item_infos.ItemType.MAPPING)
+
+def alter_item_version(item_version_id: str, item_type: item_infos.ItemType) \
+ -> werkzeug.Response:
+ form_data = flask.request.form
+ action = form_data['action']
+
+ try:
+ store = item_version_store(_app.get_haketilo_state(), item_type)
+ item_version_ref = store.get(item_version_id)
+
+ if action == 'disable_item':
+ assert isinstance(item_version_ref, st.MappingVersionRef)
+ item_version_ref.update_mapping_status(st.EnabledStatus.DISABLED)
+ elif action == 'unenable_item':
+ assert isinstance(item_version_ref, st.MappingVersionRef)
+ item_version_ref.update_mapping_status(st.EnabledStatus.NO_MARK)
+ elif action in ('enable_item_version', 'freeze_to_version'):
+ assert isinstance(item_version_ref, st.MappingVersionRef)
+ item_version_ref.update_mapping_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.EXACT_VERSION,
+ )
+ elif action == 'unfreeze_item':
+ assert isinstance(item_version_ref, st.MappingVersionRef)
+ item_version_ref.update_mapping_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.NOT_FROZEN,
+ )
+ elif action == 'freeze_to_repo':
+ assert isinstance(item_version_ref, st.MappingVersionRef)
+ item_version_ref.update_mapping_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.REPOSITORY,
+ )
+ elif action == 'install_item_version':
+ item_version_ref.install()
+ elif action == 'uninstall_item_version':
+ item_version_ref_after = item_version_ref.uninstall()
+ if item_version_ref_after is None:
+ url = flask.url_for(f'.{item_type.alt_name_plural}')
+ return flask.redirect(url)
+ else:
+ return show_item_version(item_version_id, item_type)
+ else:
+ raise ValueError()
+ except st.RepoCommunicationError:
+ return show_item_version(
+ item_version_id = item_version_id,
+ item_type = item_type,
+ errors = {'repo_communication_error': True}
+ )
+ except st.FileInstallationError:
+ return show_item_version(
+ item_version_id = item_version_id,
+ item_type = item_type,
+ errors = {'file_installation_error': True}
+ )
+ except st.ImpossibleSituation:
+ return show_item_version(
+ item_version_id = item_version_id,
+ item_type = item_type,
+ errors = {'impossible_situation_error': True}
+ )
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(
+ flask.url_for(
+ f'.show_{item_type.alt_name}_version',
+ item_version_id = item_version_id
+ )
+ )
+
+@bp.route('/libraries/viewversion/<string:item_version_id>', methods=['POST'])
+def alter_library_version(item_version_id: str) -> werkzeug.Response:
+ return alter_item_version(item_version_id, item_infos.ItemType.RESOURCE)
+
+@bp.route('/packages/viewversion/<string:item_version_id>', methods=['POST'])
+def alter_package_version(item_version_id: str) -> werkzeug.Response:
+ return alter_item_version(item_version_id, item_infos.ItemType.MAPPING)
+
+def show_file(
+ item_version_id: str,
+ item_type: item_infos.ItemType,
+ file_type: str,
+ name: str,
+) -> werkzeug.Response:
+ if file_type not in ('license', 'web_resource'):
+ flask.abort(404)
+
+ try:
+ store = item_version_store(_app.get_haketilo_state(), item_type)
+ item_version_ref = store.get(item_version_id)
+
+ try:
+ if file_type == 'license':
+ file_data = item_version_ref.get_license_file(name)
+ else:
+ assert isinstance(item_version_ref, st.ResourceVersionRef)
+ file_data = item_version_ref.get_resource_file(name)
+
+ return werkzeug.Response(
+ file_data.contents,
+ mimetype = file_data.mime_type
+ )
+ except st.MissingItemError:
+ if file_type == 'license':
+ url = item_version_ref.get_upstream_license_file_url(name)
+ else:
+ assert isinstance(item_version_ref, st.ResourceVersionRef)
+ url = item_version_ref.get_upstream_resource_file_url(name)
+
+ return flask.redirect(url)
+
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/packages/viewversion/<string:item_version_id>/<string:file_type>/<path:name>')
+def show_mapping_file(item_version_id: str, file_type: str, name: str) \
+ -> werkzeug.Response:
+ item_type = item_infos.ItemType.MAPPING
+ return show_file(item_version_id, item_type, file_type, name)
+
+@bp.route('/libraries/viewversion/<string:item_version_id>/<string:file_type>/<path:name>')
+def show_resource_file(item_version_id: str, file_type: str, name: str) \
+ -> werkzeug.Response:
+ item_type = item_infos.ItemType.RESOURCE
+ return show_file(item_version_id, item_type, file_type, name)
+
+@bp.route('/libraries/viewdep/<string:item_version_id>/<string:dep_identifier>')
+def show_library_dep(item_version_id: str, dep_identifier: str) \
+ -> werkzeug.Response:
+ state = _app.get_haketilo_state()
+
+ try:
+ store = state.resource_version_store()
+ dep_id = store.get(item_version_id).get_dependency(dep_identifier).id
+ url = flask.url_for('.show_library_version', item_version_id=dep_id)
+ except st.MissingItemError:
+ try:
+ versionless_store = state.resource_store()
+ item_ref = versionless_store.get_by_identifier(dep_identifier)
+ url = flask.url_for('.show_library', item_id=item_ref.id)
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(url)
+
+@bp.route('/<string:item_type>/viewrequired/<string:item_version_id>/<string:required_identifier>')
+def show_required_mapping(
+ item_type: str,
+ item_version_id: str,
+ required_identifier: str
+) -> werkzeug.Response:
+ state = _app.get_haketilo_state()
+
+ if item_type not in ('package', 'library'):
+ flask.abort(404)
+
+ found = False
+
+ if item_type == 'package':
+ try:
+ ref = state.mapping_version_store().get(item_version_id)
+ mapping_ver_id = ref.get_required_mapping(required_identifier).id
+ url = flask.url_for(
+ '.show_package_version',
+ item_version_id = mapping_ver_id
+ )
+ found = True
+ except st.MissingItemError:
+ pass
+
+ if not found:
+ try:
+ versionless_store = state.mapping_store()
+ mapping_ref = versionless_store\
+ .get_by_identifier(required_identifier)
+ url = flask.url_for('.show_package', item_id=mapping_ref.id)
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(url)
+
+@bp.route('/package/viewlibrary/<string:item_version_id>/<string:pattern>/<string:lib_identifier>')
+def show_package_library(item_version_id: str, pattern: str, lib_identifier: str) \
+ -> werkzeug.Response:
+ state = _app.get_haketilo_state()
+
+ try:
+ ref = state.mapping_version_store().get(item_version_id)
+
+ try:
+ resource_ver_ref = \
+ ref.get_payload_resource(unquote(pattern), lib_identifier)
+ url = flask.url_for(
+ '.show_library_version',
+ item_version_id = resource_ver_ref.id
+ )
+ except st.MissingItemError:
+ resource_ref = state.resource_store().get_by_identifier(
+ lib_identifier
+ )
+ url = flask.url_for('.show_library', item_id=resource_ref.id)
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(url)
+
+@bp.route('/package/viewbypayload/<string:payload_id>/<string:package_identifier>')
+def show_payload_package(payload_id: str, package_identifier: str) \
+ -> werkzeug.Response:
+ state = _app.get_haketilo_state()
+
+ try:
+ ref = state.payload_store().get(payload_id)
+
+ try:
+ mapping_ver_ref = ref.get_display_info().mapping_info.ref
+ url = flask.url_for(
+ '.show_package_version',
+ item_version_id = mapping_ver_ref.id
+ )
+ except st.MissingItemError:
+ mapping_ref = state.mapping_store().get_by_identifier(
+ package_identifier
+ )
+ url = flask.url_for('.show_package', item_id=mapping_ref.id)
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(url)
diff --git a/src/hydrilla/proxy/web_ui/items_import.py b/src/hydrilla/proxy/web_ui/items_import.py
new file mode 100644
index 0000000..f94768f
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/items_import.py
@@ -0,0 +1,198 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Proxy web UI packages loading.
+#
+# 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.
+
+"""
+.....
+"""
+
+import tempfile
+import zipfile
+import re
+import json
+import typing as t
+
+from pathlib import Path
+
+import flask
+import werkzeug
+
+from ...url_patterns import normalize_pattern
+from ...builder import build
+from ... import versions
+from .. import state as st
+from . import _app
+
+
+bp = flask.Blueprint('import', __package__)
+
+@bp.route('/import', methods=['GET'])
+def items_import(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response:
+ pattern = flask.request.args.get('pattern')
+ if pattern is None:
+ extra_args = {}
+ else:
+ extra_args = {'pattern': normalize_pattern(pattern)}
+
+ html = flask.render_template('import.html.jinja', **errors, **extra_args)
+ return flask.make_response(html, 200)
+
+def items_import_from_file() -> werkzeug.Response:
+ zip_file_storage = flask.request.files.get('items_zipfile')
+ if zip_file_storage is None:
+ return items_import()
+
+ with tempfile.TemporaryDirectory() as tmpdir_str:
+ tmpdir = Path(tmpdir_str)
+ tmpdir_child = tmpdir / 'childdir'
+ tmpdir_child.mkdir()
+
+ try:
+ with zipfile.ZipFile(zip_file_storage) as zip_file:
+ zip_file.extractall(tmpdir_child)
+ except:
+ return items_import({'uploaded_file_not_zip': True})
+
+ extracted_top_level_files = tuple(tmpdir_child.iterdir())
+ if extracted_top_level_files == ():
+ return items_import({'invalid_uploaded_malcontent': True})
+
+ if len(extracted_top_level_files) == 1 and \
+ extracted_top_level_files[0].is_dir():
+ malcontent_dir_path = extracted_top_level_files[0]
+ else:
+ malcontent_dir_path = tmpdir_child
+
+ try:
+ _app.get_haketilo_state().import_items(malcontent_dir_path)
+ except:
+ return items_import({'invalid_uploaded_malcontent': True})
+
+ return flask.redirect(flask.url_for('items.packages'))
+
+identifier_re = re.compile(r'^[-0-9a-z.]+$')
+
+def item_import_ad_hoc() -> werkzeug.Response:
+ form = flask.request.form
+ def get_as_str(field_name: str) -> str:
+ value = form[field_name]
+ assert isinstance(value, str)
+ return value.strip()
+
+ try:
+ identifier = get_as_str('identifier')
+ assert identifier
+ assert identifier_re.match(identifier)
+ except:
+ return items_import({'invalid_ad_hoc_identifier': True})
+
+ long_name = get_as_str('long_name') or identifier
+
+ resource_ref = {'identifier': identifier}
+
+ try:
+ ver = versions.parse(get_as_str('version') or '1')
+ except:
+ return items_import({'invalid_ad_hoc_version': True})
+
+ try:
+ pat_str = get_as_str('patterns')
+ patterns = [
+ normalize_pattern(p.strip())
+ for p in pat_str.split('\n')
+ if p and not p.isspace()
+ ]
+ assert patterns
+ except:
+ return items_import({'invalid_ad_hoc_patterns': True})
+
+ common_definition_fields: t.Mapping[str, t.Any] = {
+ 'identifier': identifier,
+ 'long_name': long_name,
+ 'version': ver,
+ 'description': get_as_str('description')
+ }
+
+ schema_url = \
+ 'https://hydrilla.koszko.org/schemas/package_source-1.schema.json'
+
+ package_index_json = {
+ '$schema': schema_url,
+ 'source_name': 'haketilo-ad-hoc-package',
+ 'copyright': [],
+ 'upstream_url': '<local ad hoc package>',
+ 'definitions': [{
+ **common_definition_fields,
+ 'type': 'mapping',
+ 'payloads': dict((p, resource_ref) for p in patterns)
+ }, {
+ **common_definition_fields,
+ 'type': 'resource',
+ 'revision': 1,
+ 'dependencies': [],
+ 'scripts': [{'file': 'script.js'}]
+ }]
+ }
+
+ with tempfile.TemporaryDirectory() as tmpdir_str:
+ tmpdir = Path(tmpdir_str)
+
+ source_dir = tmpdir / 'src'
+ source_dir.mkdir()
+
+ malcontent_dir = tmpdir / 'malcontent'
+ malcontent_dir.mkdir()
+
+ license_text = get_as_str('license_text')
+ if license_text:
+ package_index_json['copyright'] = [{'file': 'COPYING'}]
+ (source_dir / 'COPYING').write_text(license_text)
+
+ (source_dir / 'script.js').write_text(get_as_str('script_text'))
+
+ (source_dir / 'index.json').write_text(json.dumps(package_index_json))
+
+ try:
+ builder_args = ['-s', str(source_dir), '-d', str(malcontent_dir)]
+ build.perform(builder_args, standalone_mode=False)
+ _app.get_haketilo_state().import_items(malcontent_dir)
+ except:
+ import traceback
+ traceback.print_exc()
+ return items_import({'invalid_ad_hoc_package': True})
+
+ return flask.redirect(flask.url_for('items.packages'))
+
+@bp.route('/import', methods=['POST'])
+def items_import_post() -> werkzeug.Response:
+ action = flask.request.form['action']
+
+ if action == 'import_from_file':
+ return items_import_from_file()
+ elif action == 'import_ad_hoc':
+ return item_import_ad_hoc()
+ else:
+ raise ValueError()
diff --git a/src/hydrilla/proxy/web_ui/prompts.py b/src/hydrilla/proxy/web_ui/prompts.py
new file mode 100644
index 0000000..b5e052d
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/prompts.py
@@ -0,0 +1,181 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Proxy web UI pages that may be shown to the user without manual navigation to
+# Haketilo meta-site.
+#
+# 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.
+
+import typing as t
+
+from urllib.parse import urlencode
+
+from itsdangerous.url_safe import URLSafeSerializer
+import flask
+import werkzeug
+
+from .. import state as st
+from . import _app
+
+
+bp = flask.Blueprint('prompts', __package__)
+
+
+def deserialized_request_details(salt: str) -> t.Mapping[str, str]:
+ serializer = URLSafeSerializer(
+ _app.get_haketilo_state().get_secret(),
+ salt = salt
+ )
+
+ return serializer.loads(flask.request.args['details'])
+
+
+@bp.route('/auto_install_error', methods=['GET'])
+def auto_install_error_prompt(errors: t.Mapping[str, bool] = {}) \
+ -> werkzeug.Response:
+ try:
+ details = deserialized_request_details('auto_install_error')
+ except:
+ return flask.redirect(flask.url_for('home'))
+
+ try:
+ payload_store = _app.get_haketilo_state().payload_store()
+ payload_ref = payload_store.get(details['payload_id'])
+
+ display_info = payload_ref.get_display_info()
+
+ if not display_info.has_problems:
+ return flask.redirect(details['next_url'])
+
+ html = flask.render_template(
+ 'prompts/auto_install_error.html.jinja',
+ display_info = display_info,
+ **errors
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/auto_install_error', methods=['POST'])
+def auto_install_error_prompt_post() -> werkzeug.Response:
+ try:
+ details = deserialized_request_details('auto_install_error')
+ except:
+ return flask.redirect(flask.url_for('home'), code=303)
+
+ form_data = flask.request.form
+ action = form_data['action']
+
+ mapping_ver_id = str(int(form_data['mapping_ver_id']))
+ payload_id = str(int(details['payload_id']))
+
+ state = _app.get_haketilo_state()
+
+ try:
+ mapping_ver_store = state.mapping_version_store()
+ mapping_ver_ref = mapping_ver_store.get(mapping_ver_id)
+
+ payload_store = _app.get_haketilo_state().payload_store()
+ payload_ref = payload_store.get(payload_id)
+
+ if action == 'disable_mapping':
+ mapping_ver_ref.update_mapping_status(st.EnabledStatus.DISABLED)
+ elif action == 'retry_install':
+ payload_ref.ensure_items_installed()
+ else:
+ raise ValueError()
+ except st.RepoCommunicationError:
+ assert action == 'retry_install'
+ return auto_install_error_prompt({'repo_communication_error': True})
+ except st.FileInstallationError:
+ assert action == 'retry_install'
+ return auto_install_error_prompt({'file_installation_error': True})
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(details['next_url'])
+
+
+@bp.route('/package_suggestion', methods=['GET'])
+def package_suggestion_prompt(errors: t.Mapping[str, bool] = {}) \
+ -> werkzeug.Response:
+ try:
+ details = deserialized_request_details('package_suggestion')
+ except:
+ return flask.redirect(flask.url_for('home'))
+
+ try:
+ payload_store = _app.get_haketilo_state().payload_store()
+ payload_ref = payload_store.get(details['payload_id'])
+
+ display_info = payload_ref.get_display_info()
+
+ if display_info.mapping_info.active != st.ActiveStatus.AUTO:
+ return flask.redirect(details['next_url'])
+
+ html = flask.render_template(
+ 'prompts/package_suggestion.html.jinja',
+ display_info = display_info,
+ **errors
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/package_suggestion', methods=['POST'])
+def package_suggestion_prompt_post() -> werkzeug.Response:
+ try:
+ details = deserialized_request_details('package_suggestion')
+ except:
+ return flask.redirect(flask.url_for('home'))
+
+ form_data = flask.request.form
+ action = form_data['action']
+
+ mapping_ver_id = str(int(form_data['mapping_ver_id']))
+
+ state = _app.get_haketilo_state()
+
+ try:
+ mapping_ver_store = state.mapping_version_store()
+ mapping_ver_ref = mapping_ver_store.get(mapping_ver_id)
+
+ if action == 'disable_mapping':
+ mapping_ver_ref.update_mapping_status(st.EnabledStatus.DISABLED)
+ elif action == 'enable_mapping':
+ mapping_ver_ref.update_mapping_status(
+ enabled = st.EnabledStatus.ENABLED,
+ frozen = st.FrozenStatus.EXACT_VERSION
+ )
+ else:
+ raise ValueError()
+ except st.RepoCommunicationError:
+ assert action == 'enable_mapping'
+ return package_suggestion_prompt({'repo_communication_error': True})
+ except st.FileInstallationError:
+ assert action == 'enable_mapping'
+ return package_suggestion_prompt({'file_installation_error': True})
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(details['next_url'])
diff --git a/src/hydrilla/proxy/web_ui/repos.py b/src/hydrilla/proxy/web_ui/repos.py
new file mode 100644
index 0000000..bdccd76
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/repos.py
@@ -0,0 +1,137 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Proxy web UI repos view.
+#
+# 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.
+
+"""
+.....
+"""
+
+import typing as t
+
+import flask
+import werkzeug
+
+from .. import state as st
+from . import _app
+
+
+bp = flask.Blueprint('repos', __package__)
+
+@bp.route('/repos/add', methods=['GET'])
+def add_repo(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response:
+ html = flask.render_template('repos/add.html.jinja', **errors)
+ return flask.make_response(html, 200)
+
+@bp.route('/repos/add', methods=['POST'])
+def add_repo_post() -> werkzeug.Response:
+ form_data = flask.request.form
+ if 'name' not in form_data or 'url' not in form_data:
+ return add_repo()
+
+ try:
+ new_repo_ref = _app.get_haketilo_state().repo_store().add(
+ name = form_data['name'],
+ url = form_data['url']
+ )
+ except st.RepoNameInvalid:
+ return add_repo({'repo_name_invalid': True})
+ except st.RepoNameTaken:
+ return add_repo({'repo_name_taken': True})
+ except st.RepoUrlInvalid:
+ return add_repo({'repo_url_invalid': True})
+
+ return flask.redirect(flask.url_for('.show_repo', repo_id=new_repo_ref.id))
+
+@bp.route('/repos')
+def repos() -> werkzeug.Response:
+ repo_store = _app.get_haketilo_state().repo_store()
+
+ local_semirepo_info, *repo_infos = repo_store.get_display_infos()
+
+ html = flask.render_template(
+ 'repos/index.html.jinja',
+ local_semirepo_info = local_semirepo_info,
+ display_infos = repo_infos
+ )
+ return flask.make_response(html, 200)
+
+@bp.route('/repos/view/<string:repo_id>')
+def show_repo(repo_id: str, errors: t.Mapping[str, bool] = {}) \
+ -> werkzeug.Response:
+ try:
+ store = _app.get_haketilo_state().repo_store()
+ display_info = store.get(repo_id).get_display_info()
+
+ html = flask.render_template(
+ 'repos/show_single.html.jinja',
+ display_info = display_info,
+ **errors
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/repos/view/<string:repo_id>', methods=['POST'])
+def alter_repo(repo_id: str) -> werkzeug.Response:
+ form_data = flask.request.form
+ action = form_data['action']
+
+ repo_id = str(int(repo_id))
+ if repo_id == '1':
+ # Protect local semi-repo.
+ flask.abort(403)
+
+ try:
+ repo_ref = _app.get_haketilo_state().repo_store().get(repo_id)
+
+ if action == 'remove_repo':
+ repo_ref.remove()
+ return flask.redirect(flask.url_for('.repos'))
+ elif action == 'refresh_repo':
+ repo_ref.refresh()
+ elif action == 'update_repo_data':
+ repo_ref.update(
+ url = form_data.get('url'),
+ name = form_data.get('name')
+ )
+ else:
+ raise ValueError()
+ except st.RepoNameInvalid:
+ return show_repo(repo_id, {'repo_name_invalid': True})
+ except st.RepoNameTaken:
+ return show_repo(repo_id, {'repo_name_taken': True})
+ except st.RepoUrlInvalid:
+ return show_repo(repo_id, {'repo_url_invalid': True})
+ except st.RepoCommunicationError:
+ return show_repo(repo_id, {'repo_communication_error': True})
+ except st.FileInstallationError:
+ return show_repo(repo_id, {'file_installation_error': True})
+ except st.RepoApiVersionUnsupported:
+ return show_repo(repo_id, {'repo_api_version_unsupported': True})
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(flask.url_for('.show_repo', repo_id=repo_id))
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
+ )
diff --git a/src/hydrilla/proxy/web_ui/rules.py b/src/hydrilla/proxy/web_ui/rules.py
new file mode 100644
index 0000000..606d33f
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/rules.py
@@ -0,0 +1,122 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Proxy web UI script blocking rule management.
+#
+# 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.
+
+import typing as t
+
+import flask
+import werkzeug
+
+from .. import state as st
+from . import _app
+
+
+bp = flask.Blueprint('rules', __package__)
+
+@bp.route('/rules/add', methods=['GET'])
+def add_rule(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response:
+ html = flask.render_template('rules/add.html.jinja', **errors)
+ return flask.make_response(html, 200)
+
+@bp.route('/rules/add', methods=['POST'])
+def add_rule_post() -> werkzeug.Response:
+ form_data = flask.request.form
+
+ try:
+ new_rule_ref = _app.get_haketilo_state().rule_store().add(
+ pattern = form_data['pattern'],
+ allow = form_data['allow'] == 'true'
+ )
+ except st.RulePatternInvalid:
+ return add_rule({'rule_pattern_invalid': True})
+
+ return flask.redirect(flask.url_for('.show_rule', rule_id=new_rule_ref.id))
+
+@bp.route('/rules', methods=['GET'])
+def rules(errors: t.Mapping[str, bool] = {}) -> werkzeug.Response:
+ store = _app.get_haketilo_state().rule_store()
+
+ html = flask.render_template(
+ 'rules/index.html.jinja',
+ display_infos = store.get_display_infos(),
+ **errors
+ )
+ return flask.make_response(html, 200)
+
+@bp.route('/rules/view/<string:rule_id>')
+def show_rule(rule_id: str, errors: t.Mapping[str, bool] = {}) \
+ -> werkzeug.Response:
+ try:
+ store = _app.get_haketilo_state().rule_store()
+ display_info = store.get(rule_id).get_display_info()
+
+ html = flask.render_template(
+ 'rules/show_single.html.jinja',
+ display_info = display_info,
+ **errors
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/rules/view/<string:rule_id>', methods=['POST'])
+def alter_rule(rule_id: str) -> werkzeug.Response:
+ form_data = flask.request.form
+ action = form_data['action']
+
+ try:
+ rule_ref = _app.get_haketilo_state().rule_store().get(rule_id)
+
+ if action == 'remove_rule':
+ rule_ref.remove()
+ return flask.redirect(flask.url_for('.rules'))
+ elif action == 'update_rule_data':
+ allow_param = form_data.get('allow')
+ rule_ref.update(
+ pattern = form_data.get('pattern'),
+ allow = None if allow_param is None else allow_param == 'true'
+ )
+ else:
+ raise ValueError()
+ except st.RulePatternInvalid:
+ return show_rule(rule_id, {'rule_pattern_invalid': True})
+ except st.MissingItemError:
+ flask.abort(404)
+
+ return flask.redirect(flask.url_for('.show_rule', rule_id=rule_id))
+
+@bp.route('/rules/viewbypattern')
+def show_pattern_rule() -> werkzeug.Response:
+ pattern = flask.request.args['pattern']
+
+ try:
+ store = _app.get_haketilo_state().rule_store()
+ rule_ref = store.get_by_pattern(pattern)
+ except st.MissingItemError:
+ html = flask.render_template('rules/add.html.jinja', pattern=pattern)
+ return flask.make_response(html, 200)
+
+ return flask.redirect(flask.url_for('.show_rule', rule_id=rule_ref.id))
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..d12dc57
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/hkt_mitm_it_base.html.jinja
@@ -0,0 +1,121 @@
+{#
+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 "web_ui_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: auto;
+ }
+
+ 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 %}
+
+{% macro hkt_doc_link(page_name) %}
+ {% set doc_url = url_for('home.home_doc', page=page_name) %}
+ {{ doc_link(doc_url) }}
+{% endmacro %}
diff --git a/src/hydrilla/proxy/web_ui/templates/import.html.jinja b/src/hydrilla/proxy/web_ui/templates/import.html.jinja
new file mode 100644
index 0000000..34f1b66
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/import.html.jinja
@@ -0,0 +1,125 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI item loading 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.import.title') }} {% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ input[type="file"]::-webkit-file-selector-button,
+ input[type="file"]::file-selector-button {
+ display: none;
+ }
+
+ input[type="file"] {
+ display: block;
+ font-size: inherit;
+ font-style: inherit;
+ }
+{% endblock %}
+
+{% block main %}
+ <h3>{{ _('web_ui.import.heading') }}</h3>
+
+ <h4>{{ _('web_ui.import.heading_import_from_file') }}</h4>
+
+ <form method="POST" enctype="multipart/form-data">
+ <input name="action" type="hidden" value="import_from_file">
+
+ {% if uploaded_file_not_zip is defined %}
+ {{ error_note(_('web_ui.err.uploaded_file_not_zip')) }}
+ {% endif %}
+
+ {% if invalid_uploaded_malcontent is defined %}
+ {{ error_note(_('web_ui.err.invalid_uploaded_malcontent')) }}
+ {% endif %}
+
+ <input id="items_zipfile" name="items_zipfile" type="file"
+ accept=".zip,application/zip" required=""
+ class="block-with-bottom-margin">
+
+ <label class="green-button block-with-bottom-margin" for="items_zipfile">
+ {{ _('web_ui.import.choose_zipfile_button') }}
+ </label>
+
+ <div class="horizontal-separator"></div>
+
+ <div class="flex-row">
+ <button class="green-button">
+ {{ _('web_ui.import.install_from_file_button') }}
+ </button>
+ </div>
+ </form>
+
+ <div class="horizontal-separator"></div>
+
+ <h4>
+ {{ _('web_ui.import.heading_import_ad_hoc') }}
+ {{ hkt_doc_link('packages') }}
+ </h4>
+
+ {% if invalid_ad_hoc_package is defined %}
+ {{ error_note(_('web_ui.err.invalid_ad_hoc_package')) }}
+ {% endif %}
+
+ <form method="POST">
+ <input name="action" type="hidden" value="import_ad_hoc">
+
+ {{ label(_('web_ui.import.identifier_field_label'), 'identifier') }}
+ {% if invalid_ad_hoc_identifier is defined %}
+ {{ error_note(_('web_ui.err.invalid_ad_hoc_identifier')) }}
+ {% endif %}
+ {{ form_field('identifier') }}
+
+ {{ label(_('web_ui.import.long_name_field_label'), 'long_name') }}
+ {{ form_field('long_name', required=false) }}
+
+ {{ label(_('web_ui.import.version_field_label'), 'version') }}
+ {% if invalid_ad_hoc_version is defined %}
+ {{ error_note(_('web_ui.err.invalid_ad_hoc_version')) }}
+ {% endif %}
+ {{ form_field('version', required=false) }}
+
+ {{ label(_('web_ui.import.description_field_label'), 'description') }}
+ {{ form_field('description', required=false, height=3) }}
+
+ {% call label(_('web_ui.import.patterns_field_label'), 'patterns') %}
+ {{ hkt_doc_link('url_patterns') }}
+ {% endcall %}
+ {% if invalid_ad_hoc_patterns is defined %}
+ {{ error_note(_('web_ui.err.invalid_ad_hoc_patterns')) }}
+ {% endif %}
+ {{ form_field('patterns', height=3, initial_value=pattern|default(none)) }}
+
+ {{ label(_('web_ui.import.script_text_field_label'), 'script_text') }}
+ {{ form_field('script_text', required=false, height=15) }}
+
+ {{ label(_('web_ui.import.lic_text_field_label'), 'license_text') }}
+ {{ form_field('license_text', required=false, height=10) }}
+
+ <div class="flex-row">
+ <button class="green-button">
+ {{ _('web_ui.import.install_ad_hoc_button') }}
+ </button>
+ </div>
+ </form>
+{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/index.html.jinja b/src/hydrilla/proxy/web_ui/templates/index.html.jinja
new file mode 100644
index 0000000..d6a47f0
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/index.html.jinja
@@ -0,0 +1,365 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI home 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.home.title') }} {% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/checkbox_tricks_style.css.jinja' %}
+{% endblock %}
+
+{% import 'import/checkbox_tricks.html.jinja' as tricks %}
+
+{% block main %}
+ {% if file_installation_error is defined %}
+ {{ error_note(_('web_ui.err.file_installation_error')) }}
+ {% endif %}
+
+ {% if impossible_situation_error is defined %}
+ {{ error_note(_('web_ui.err.impossible_situation_error')) }}
+ {% endif %}
+
+ <h3>
+ {{ _('web_ui.home.heading.welcome_to_haketilo') }}
+ </h3>
+
+ <p>
+ {{ _('web_ui.home.this_is_haketilo_page') }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+
+ <h4>
+ {{ _('web_ui.home.heading.about_haketilo') }}
+ </h4>
+
+ <p class="has-colored-links">
+ {{ _('web_ui.home.html.haketilo_is_blah_blah')|safe }}
+ </p>
+
+ <p class="has-colored-links">
+ {% set fmt = _('web_ui.home.html.see_haketilo_doc_{url}') %}
+ {% set doc_url = url_for('home.home_doc', page='doc_index') %}
+ {{ fmt.format(url=doc_url|e)|safe }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+
+ {% if request.url.startswith('http://') %}
+ <h4>
+ {{ _('web_ui.home.heading.configuring_browser_for_haketilo') }}
+ </h4>
+
+ <p class="has-colored-links">
+ {{ _('web_ui.home.html.to_add_certs_do_xyz')|safe }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}
+
+ <h4>
+ {{ _('web_ui.home.heading.options') }}
+ </h4>
+
+ {{ label(_('web_ui.home.choose_language_label')) }}
+
+ {% call unordered_list() %}
+ {%
+ for lang_name, lang_code in [
+ ('english', 'en_US'),
+ ('polski', 'pl_PL')
+ ]
+ %}
+ {% call list_entry() %}
+ <form method="POST" class="inline">
+ <input type="hidden" name="action" value="set_lang">
+ <input type="hidden" name="locale" value="{{ lang_code }}">
+ <button>{{ lang_name }}</button>
+ </form>
+ {% endcall %}
+ {% endfor %}
+ {% endcall %}
+
+ {% call label(_('web_ui.home.mapping_usage_mode_label')) %}
+ {{ hkt_doc_link('packages') }}
+ {% endcall %}
+
+ {% set use_enabled_but_classes = ['green-button'] %}
+ {% set use_auto_but_classes = ['green-button'] %}
+ {% set use_question_but_classes = ['green-button'] %}
+
+ <p>
+ {% if settings.mapping_use_mode == MappingUseMode.WHEN_ENABLED %}
+ {% do use_enabled_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.packages_are_used_when_enabled') }}
+ {% elif settings.mapping_use_mode == MappingUseMode.QUESTION %}
+ {% do use_question_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.user_gets_asked_whether_to_enable_package') }}
+ {% else %}
+ {# settings.mapping_use_mode == MappingUseMode.AUTO #}
+ {% do use_auto_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.packages_are_used_automatically') }}
+ {% endif %}
+ </p>
+
+ {{
+ button_row([
+ (use_enabled_but_classes,
+ _('web_ui.home.use_enabled_button'),
+ {'action': 'use_enabled'}),
+ (use_question_but_classes,
+ _('web_ui.home.use_question_button'),
+ {'action': 'use_question'}),
+ (use_auto_but_classes,
+ _('web_ui.home.use_auto_button'),
+ {'action': 'use_auto'})
+ ])
+ }}
+
+ <div class="horizontal-separator"></div>
+
+ {% call label(_('web_ui.home.script_blocking_mode_label')) %}
+ {{ hkt_doc_link('script_blocking') }}
+ {% endcall %}
+
+ {% set allow_but_classes = ['red-button'] %}
+ {% set block_but_classes = ['blue-button'] %}
+
+ <p>
+ {% if settings.default_allow_scripts %}
+ {% do allow_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.scripts_are_allowed_by_default') }}
+ {% else %}
+ {% do block_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.scripts_are_blocked_by_default') }}
+ {% endif %}
+ </p>
+
+ {% set allow_but_text = _('web_ui.home.allow_scripts_button') %}
+ {% set block_but_text = _('web_ui.home.block_scripts_button') %}
+
+ {{
+ button_row([
+ (allow_but_classes, allow_but_text, {'action': 'allow_scripts'}),
+ (block_but_classes, block_but_text, {'action': 'block_scripts'})
+ ])
+ }}
+
+ <div class="horizontal-separator"></div>
+
+ {% call label(_('web_ui.home.advanced_features_label')) %}
+ {{ hkt_doc_link('advanced_ui_features') }}
+ {% endcall %}
+
+ {% set advanced_user_but_classes = ['red-button'] %}
+ {% set simple_user_but_classes = ['blue-button'] %}
+
+ <p>
+ {% if settings.advanced_user %}
+ {% do advanced_user_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.user_is_advanced_user') }}
+ {% else %}
+ {% do simple_user_but_classes.append('disabled-button') %}
+ {{ _('web_ui.home.user_is_simple_user') }}
+ {% endif %}
+ </p>
+
+ {{
+ button_row([
+ (advanced_user_but_classes,
+ _('web_ui.home.user_make_advanced_button'),
+ {'action': 'user_make_advanced'}),
+ (simple_user_but_classes,
+ _('web_ui.home.user_make_simple_button'),
+ {'action': 'user_make_simple'})
+ ])
+ }}
+
+ {% if settings.update_waiting %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.home.update_waiting_label')) }}
+
+ <p>
+ {{ _('web_ui.home.update_is_awaiting') }}
+ </p>
+
+ {% set update_but_text = _('web_ui.home.update_items_button') %}
+
+ {{
+ button_row([
+ (['green-button'], update_but_text, {'action': 'upate_all_items'})
+ ])
+ }}
+ {% endif %}
+
+ {% if orphan_item_stats.mappings > 0 or orphan_item_stats.resources > 0 %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.home.orphans_label')) }}
+
+ <p>
+ {% if settings.advanced_user %}
+ {% if orphan_item_stats.mappings > 0 %}
+ {{
+ _('web_ui.home.orphans_to_delete_{mappings}')
+ .format(mappings = orphan_item_stats.mappings)
+ }}
+ {% else %}
+ {{ _('web_ui.home.orphans_to_delete_exist') }}
+ {% endif %}
+ {% else %}
+ {{
+ _('web_ui.home.orphans_to_delete_{mappings}_{resources}')
+ .format(
+ mappings = orphan_item_stats.mappings,
+ resources = orphan_item_stats.resources
+ )
+ }}
+ {% endif %}
+ </p>
+
+ {% set prune_but_text = _('web_ui.home.prune_orphans_button') %}
+
+ {{
+ button_row([
+ (['green-button'], prune_but_text, {'action': 'prune_orphans'})
+ ])
+ }}
+ {% endif %}
+
+ <div class="horizontal-separator"></div>
+
+ {% call label(_('web_ui.home.popup_settings_label')) %}
+ {{ hkt_doc_link('popup') }}
+ {% endcall %}
+
+ {%
+ macro render_popup_settings(
+ page_type,
+ initial_show = false,
+ popup_change_but_base_classes = ['red-button', 'blue-button']
+ )
+ %}
+ {% set radio_id = 'popup_settings_radio_' ~ page_type %}
+ {{ tricks.sibling_hider_radio('popup_settings', radio_id, initial_show) }}
+
+ <div>
+ <p>
+ {{ _('web_ui.home.configure_popup_settings_on_pages_with') }}
+ </p>
+
+ <div class="flex-row">
+ {%
+ for but_page_type, but_text in [
+ ('jsallowed', _('web_ui.home.popup_settings_jsallowed_button')),
+ ('jsblocked', _('web_ui.home.popup_settings_jsblocked_button')),
+ ('payloadon', _('web_ui.home.popup_settings_payloadon_button'))
+ ]
+ %}
+ {% set attrs, classes = {}, ['green-button'] %}
+
+ {% if but_page_type == page_type %}
+ {% do classes.append('disabled-button') %}
+ {% else %}
+ {% set but_radio_id = 'popup_settings_radio_' ~ but_page_type %}
+ {% do attrs.update({'for': but_radio_id}) %}
+ {% endif %}
+
+ {% if not loop.first %}
+ {% do classes.append('button-bordering-left') %}
+ {% endif %}
+ {% if not loop.last %}
+ {% do classes.append('button-bordering-right') %}
+ {% endif %}
+
+ {% do attrs.update({'class': classes|join(' ')}) %}
+
+ <label {{ attrs|xmlattr }}>
+ {{ but_text }}
+ </label>
+
+ {% if not loop.last %}
+ <div class="button-row-separator"></div>
+ {% endif %}
+ {% endfor %}
+ </div>
+
+ {% set popup_no_but_classes = [popup_change_but_base_classes[0]] %}
+ {% set popup_yes_but_classes = [popup_change_but_base_classes[1]] %}
+
+ {% set settings_prop = 'default_popup_' ~ page_type %}
+ {% set is_on = (settings|attr(settings_prop)).keyboard_trigger %}
+
+ {% if is_on %}
+ {% do popup_yes_but_classes.append('disabled-button') %}
+ {% else %}
+ {% do popup_no_but_classes.append('disabled-button') %}
+ {% endif %}
+
+ <p>
+ {{ caller(is_on) }}
+ </p>
+
+ {{
+ button_row([
+ (popup_no_but_classes,
+ _('web_ui.home.popup_no_button'),
+ {'action': 'popup_no_when_' ~ page_type}),
+ (popup_yes_but_classes,
+ _('web_ui.home.popup_yes_button'),
+ {'action': 'popup_yes_when_' ~ page_type})
+ ])
+ }}
+ </div>
+ {% endmacro %}
+
+ {% set but_classes = ['green-button', 'green-button'] %}
+ {% call(popup_is_on) render_popup_settings('jsallowed', true, but_classes) %}
+ {% if popup_is_on %}
+ {{ _('web_ui.home.jsallowed_popup_yes') }}
+ {% else %}
+ {{ _('web_ui.home.jsallowed_popup_no') }}
+ {% endif %}
+ {% endcall %}
+
+ {% call(popup_is_on) render_popup_settings('jsblocked') %}
+ {% if popup_is_on %}
+ {{ _('web_ui.home.jsblocked_popup_yes') }}
+ {% else %}
+ {{ _('web_ui.home.jsblocked_popup_no') }}
+ {% endif %}
+ {% endcall %}
+
+ {% call(popup_is_on) render_popup_settings('payloadon') %}
+ {% if popup_is_on %}
+ {{ _('web_ui.home.payloadon_popup_yes') }}
+ {% else %}
+ {{ _('web_ui.home.payloadon_popup_no') }}
+ {% endif %}
+ {% endcall %}
+
+ <p>
+ {{ _('web_ui.home.popup_can_be_opened_by') }}
+ </p>
+{% endblock main %}
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
new file mode 100644
index 0000000..ccfa6b9
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/item_view.html.jinja
@@ -0,0 +1,112 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI item view page template.
+
+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 "hkt_mitm_it_base.html.jinja" %}
+
+{% macro version_with_repo(info) -%}
+ {{ info.info.version_string }}
+ {%- if not info.is_local %}
+ @
+ {{ info.info.repo }}
+ {%- endif %}
+{%- endmacro %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/item_list_style.css.jinja' %}
+
+ .textcolor-gray {
+ color: #777;
+ }
+{% endblock %}
+
+{% block main %}
+ {% block top_errors %}
+ {% if file_installation_error is defined %}
+ {{ error_note(_('web_ui.err.file_installation_error')) }}
+ {% endif %}
+
+ {% if impossible_situation_error is defined %}
+ {{ error_note(_('web_ui.err.impossible_situation_error')) }}
+ {% endif %}
+
+ {% if repo_communication_error is defined %}
+ {{ error_note(_('web_ui.err.repo_communication_error')) }}
+ {% endif %}
+ {% endblock top_errors %}
+
+ {% block main_info %}
+ <h3>{% block heading required %}{% endblock %}</h3>
+ {% endblock %}
+
+ {%
+ if display_info.all_versions|length > 1 or
+ (display_info.all_versions|length == 1 and
+ (version_display_info is not defined or
+ version_display_info.ref != display_info.all_versions[0].ref))
+ %}
+ <div class="horizontal-separator"></div>
+
+ <h4>
+ {% block version_list_heading required %}
+ {% endblock %}
+ </h4>
+
+ <ul class="item-list">
+ {% for info in display_info.all_versions %}
+ {%
+ if version_display_info is not defined or
+ version_display_info.ref != info.ref
+ %}
+ {% set entry_classes = [] %}
+
+ {% if info.is_orphan or info.is_local %}
+ {% do entry_classes.append('textcolor-gray') %}
+ {% endif %}
+
+ {% if info.active == ActiveStatus.REQUIRED %}
+ {% do entry_classes.append('entry-line-blue') %}
+ {%
+ if display_info.type != ItemType.MAPPING or
+ display_info.enabled != EnabledStatus.ENABLED
+ %}
+ {% do entry_classes.append('entry-line-dashed') %}
+ {% endif %}
+ {% elif info.active == ActiveStatus.AUTO %}
+ {% do entry_classes.append('entry-line-green') %}
+ {% endif %}
+
+ <li class="{{ entry_classes|join(' ') }}">
+ {%
+ set href = url_for(
+ '.show_{}_version'.format(info.type.alt_name),
+ item_version_id = info.ref.id
+ )
+ %}
+ <a href="{{ href }}">
+ <div> {{ version_with_repo(info) }} </div>
+ </a>
+ </li>
+ {% endif %}
+ {% endfor %}
+ </ul>
+ {% endif %}{# display_info.all_versions|length > 0 #}
+{% endblock main %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja
new file mode 100644
index 0000000..4b6cdee
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/item_viewversion.html.jinja
@@ -0,0 +1,209 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI item version view page template.
+
+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 "items/item_view.html.jinja" %}
+
+{% macro item_file_list(file_specs, file_type) %}
+ <ul class="item-list has-colored-links">
+ {% for spec in file_specs %}
+ <li class="invisible-entry-line">
+ {%
+ set url = url_for(
+ '.show_{}_file'.format(version_display_info.type.value),
+ item_version_id = version_display_info.ref.id,
+ file_type = file_type,
+ name = spec.name
+ )
+ %}
+ <div>
+ <a href="{{ url }}">
+ {{ spec.name }}
+ </a>
+ </div>
+ </li>
+ {% endfor %}
+ </ul>
+{% endmacro %}
+
+{% macro item_link_list(item_specs, make_url) %}
+ <ul class="item-list has-colored-links">
+ {% for spec in item_specs %}
+ <li class="invisible-entry-line">
+ <div>
+ <a href="{{ make_url(spec) }}">
+ {{ spec.identifier }}
+ </a>
+ </div>
+ </li>
+ {% endfor %}
+ </ul>
+{% endmacro %}
+
+{% block top_errors %}
+ {% if not version_display_info.info.compatible %}
+ {{ error_note(_('web_ui.err.item_not_compatible')) }}
+ {% endif %}
+{% endblock %}
+
+{% block main_info %}
+ {{ super() }}
+
+ {{ label(_('web_ui.items.single_version.identifier_label')) }}
+
+ <p>
+ {{ version_display_info.info.identifier }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.items.single_version.version_label')) }}
+
+ <p>
+ {{ version_with_repo(version_display_info) }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+
+ {% if version_display_info.info.uuid is not none %}
+ {{ label(_('web_ui.items.single_version.uuid_label')) }}
+
+ <p>
+ {{ version_display_info.info.uuid }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}
+
+ {% if version_display_info.info.description %}
+ {{ label(_('web_ui.items.single_version.description_label')) }}
+
+ <p>
+ {{ version_display_info.info.description }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}
+
+ {{ label(_('web_ui.items.single_version.licenses_label')) }}
+
+ {% if version_display_info.info.source_copyright %}
+ {{ item_file_list(version_display_info.info.source_copyright, 'license') }}
+ {% else %}
+ <p>
+ {{ _('web_ui.items.single_version.no_license_files') }}
+ </p>
+ {% endif %}
+
+ <div class="horizontal-separator"></div>
+
+ {% if version_display_info.info.required_mappings %}
+ {{ label(_('web_ui.items.single_version.required_mappings_label')) }}
+
+ {% macro make_mapping_url(spec) -%}
+ {{
+ url_for(
+ '.show_required_mapping',
+ item_type = version_display_info.type.alt_name,
+ item_version_id = version_display_info.ref.id,
+ required_identifier = spec.identifier
+ )
+ }}
+ {%- endmacro %}
+
+ {% set required_specs = version_display_info.info.required_mappings %}
+ {{ item_link_list(required_specs, make_mapping_url) }}
+
+ <div class="horizontal-separator"></div>
+ {% endif %}
+
+ {% if version_display_info.info.min_haketilo_ver != versions.int_ver_min %}
+ {{ label(_('web_ui.items.single_version.min_haketilo_ver_label')) }}
+
+ <p>
+ {{ versions.version_string(version_display_info.info.min_haketilo_ver) }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}
+
+ {% if version_display_info.info.max_haketilo_ver != versions.int_ver_max %}
+ {{ label(_('web_ui.items.single_version.max_haketilo_ver_label')) }}
+
+ <p>
+ {{ versions.version_string(version_display_info.info.max_haketilo_ver) }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}
+
+ {% block main_info_rest required %}{% endblock %}
+
+ {%
+ if settings.advanced_user and
+ version_display_info.active != ActiveStatus.REQUIRED
+ %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.items.single_version.install_uninstall_label')) }}
+
+ {% set install_but_classes = ['green-button'] %}
+ {% set uninstall_but_classes = ['green-button'] %}
+ {% if version_display_info.installed == InstalledStatus.FAILED_TO_INSTALL %}
+ {%
+ set install_text =
+ _('web_ui.items.single_version.retry_install_button')
+ %}
+ {%
+ set uninstall_text =
+ _('web_ui.items.single_version.leave_uninstalled_button')
+ %}
+ <p>{% block item_install_failed_msg required %}{% endblock %}</p>
+ {% else %}
+ {% set install_text = _('web_ui.items.single_version.install_button') %}
+ {%
+ set uninstall_text = _('web_ui.items.single_version.uninstall_button')
+ %}
+ {% if version_display_info.installed == InstalledStatus.INSTALLED %}
+ {% do install_but_classes.append('disabled-button') %}
+ {%
+ if uninstall_disallowed is defined or
+ version_display_info.active == ActiveStatus.REQUIRED
+ %}
+ {% do uninstall_but_classes.append('disabled-button') %}
+ {% endif %}
+ <p>{% block item_is_installed_msg required %}{% endblock %}</p>
+ {% else %}
+ {# version_display_info.installed == InstalledStatus.NOT_INSTALLED #}
+ {% do uninstall_but_classes.append('disabled-button') %}
+ <p>{% block item_is_not_installed_msg required %}{% endblock %}</p>
+ {% endif %}
+ {% endif %}{# else/ version_display_info.installed == InstalledStatus.... #}
+
+ {% set uninstall_fields = {'action': 'uninstall_item_version'} %}
+ {% set install_fields = {'action': 'install_item_version'} %}
+
+ {{
+ button_row([
+ (uninstall_but_classes, uninstall_text, uninstall_fields),
+ (install_but_classes, install_text, install_fields)
+ ])
+ }}
+ {% endif %}{# settings.advanced_user #}
+{% endblock main_info %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja
new file mode 100644
index 0000000..d94d51c
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/libraries.html.jinja
@@ -0,0 +1,55 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI library list 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.libraries.title') }} {% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/item_list_style.css.jinja' %}
+
+ ul.item-list > li > a {
+ display: flex !important;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 2.2em;
+ }
+{% endblock %}
+
+{% block main %}
+ <h3>
+ {{ _('web_ui.libraries.heading') }}
+ {{ hkt_doc_link('packages') }}
+ </h3>
+
+ <ul class="item-list">
+ {% for info in display_infos %}
+ <li>
+ <a href="{{ url_for('.show_library', item_id=info.ref.id) }}">
+ <div>
+ {{ info.identifier }}
+ </div>
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja
new file mode 100644
index 0000000..f33b5b7
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/library_view.html.jinja
@@ -0,0 +1,38 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI library view 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 "items/item_view.html.jinja" %}
+
+{% block title %} {{ _('web_ui.items.single.library.title') }} {% endblock %}
+
+{% block heading %}
+ {{
+ _('web_ui.items.single.library.heading.name_{}')
+ .format(display_info.identifier)
+ }}
+{% endblock %}
+
+{% block main_info %}
+ {{ super() }}
+{% endblock %}
+
+{% block version_list_heading %}
+ {{ _('web_ui.items.single.library.version_list_heading') }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja
new file mode 100644
index 0000000..eb77fe6
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/library_viewversion.html.jinja
@@ -0,0 +1,103 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI library version view 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 "items/item_viewversion.html.jinja" %}
+
+{% block title %}
+ {{ _('web_ui.items.single_version.library.title') }}
+{% endblock %}
+
+{% block heading %}
+ {% if version_display_info.is_local %}
+ {{
+ _('web_ui.items.single_version.library_local.heading.name_{}')
+ .format(version_display_info.info.long_name)
+ }}
+ {% else %}
+ {{
+ _('web_ui.items.single_version.library.heading.name_{}')
+ .format(version_display_info.info.long_name)
+ }}
+ {% endif %}
+{% endblock %}
+
+{% block item_install_failed_msg %}
+ {{ _('web_ui.items.single_version.library.install_failed') }}
+{% endblock %}
+
+{% block item_is_installed_msg %}
+ {{ _('web_ui.items.single_version.library.is_installed') }}
+{% endblock %}
+
+{% block item_is_not_installed_msg %}
+ {{ _('web_ui.items.single_version.library.is_not_installed') }}
+{% endblock %}
+
+{% block version_list_heading %}
+ {{ _('web_ui.items.single_version.library.version_list_heading') }}
+{% endblock %}
+
+{% block main_info_rest %}
+ {{ label(_('web_ui.items.single_version.library.scripts_label')) }}
+
+ {% if version_display_info.info.scripts %}
+ {{ item_file_list(version_display_info.info.scripts, 'web_resource') }}
+ {% else %}
+ <p>
+ {{ _('web_ui.items.single_version.library.no_script_files') }}
+ </p>
+ {% endif %}
+
+ <div class="horizontal-separator"></div>
+
+ {% if version_display_info.info.dependencies %}
+ {{ label(_('web_ui.items.single_version.library.deps_label')) }}
+
+ {% macro make_dep_url(spec) -%}
+ {{
+ url_for(
+ '.show_library_dep',
+ item_version_id = version_display_info.ref.id,
+ dep_identifier = spec.identifier
+ )
+ }}
+ {%- endmacro %}
+
+ {{ item_link_list(version_display_info.info.dependencies, make_dep_url) }}
+ {% endif %}
+
+ {{ label(_('web_ui.items.single_version.library.enabled_label')) }}
+
+ <p>
+ {% if version_display_info.active == ActiveStatus.REQUIRED %}
+ {{ _('web_ui.items.single_version.library.item_required') }}
+ {%
+ elif version_display_info.active == ActiveStatus.NOT_ACTIVE or
+ settings.mapping_use_mode == MappingUseMode.WHEN_ENABLED
+ %}
+ {{ _('web_ui.items.single_version.library.item_not_activated') }}
+ {% elif settings.mapping_use_mode == MappingUseMode.QUESTION %}
+ {{ _('web_ui.items.single_version.library.item_will_be_asked_about') }}
+ {% else %}
+ {# settings.mapping_use_mode == MappingUseMode.AUTO #}
+ {{ _('web_ui.items.single_version.library.item_auto_activated') }}
+ {% endif %}
+ </p>
+{% endblock main_info_rest %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja
new file mode 100644
index 0000000..d5ba2a0
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/package_view.html.jinja
@@ -0,0 +1,127 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI package view 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 "items/item_view.html.jinja" %}
+
+{% block title %} {{ _('web_ui.items.single.package.title') }} {% endblock %}
+
+{% block heading %}
+ {{
+ _('web_ui.items.single.package.heading.name_{}')
+ .format(display_info.identifier)
+ }}
+{% endblock %}
+
+{% block main_info %}
+ {{ super() }}
+
+ {#
+ The labels and buttons below are similar to those in single package versions
+ view but not similar enough for us to be able to refactor common code.
+ #}
+
+ {{ label(_('web_ui.items.single.package.enabled_label')) }}
+
+ {% set enable_but_classes = ['blue-button'] %}
+ {% set unenable_but_classes = ['green-button'] %}
+ {% set disable_but_classes = ['red-button'] %}
+
+ {% set unenable_text = _('web_ui.items.unenable_button') %}
+ {% set disable_text = _('web_ui.items.disable_button') %}
+ {% set enable_text = _('web_ui.items.enable_button') %}
+
+ <p>
+ {% if display_info.enabled == EnabledStatus.NO_MARK %}
+ {% do unenable_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single.package.item_not_enabled') }}
+ {% elif display_info.enabled == EnabledStatus.DISABLED %}
+ {% do disable_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single.package.item_disabled') }}
+ {% else %}
+ {# display_info.enabled == EnabledStatus.ENABLED #}
+ {% do enable_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single.package.item_enabled') }}
+ {% endif %}
+ </p>
+
+ {{
+ button_row([
+ (disable_but_classes, disable_text, {'action': 'disable_item'}),
+ (unenable_but_classes, unenable_text, {'action': 'unenable_item'}),
+ (enable_but_classes, enable_text, {'action': 'enable_item'})
+ ])
+ }}
+
+ {% if display_info.enabled == EnabledStatus.ENABLED %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.items.single.package.pinning_label')) }}
+
+ {% set unpin_but_classes = ['green-button'] %}
+ {% set pin_repo_but_classes = ['green-button'] %}
+ {% set pin_ver_but_classes = ['green-button'] %}
+
+ {% set unpin_text = _('web_ui.items.single.package.unpin_button') %}
+
+ {% if display_info.active_version.is_local %}
+ {%
+ set pin_repo_text =
+ _('web_ui.items.single.package.pin_local_repo_button')
+ %}
+ {% else %}
+ {% set pin_repo_text = _('web_ui.items.single.package.pin_repo_button') %}
+ {% endif %}
+
+ {% set pin_ver_text = _('web_ui.items.single.package.pin_ver_button') %}
+
+ <p>
+ {% if display_info.frozen == FrozenStatus.NOT_FROZEN %}
+ {% do unpin_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single.package.not_pinned') }}
+ {% elif display_info.frozen == FrozenStatus.REPOSITORY %}
+ {% do pin_repo_but_classes.append('disabled-button') %}
+ {% if display_info.active_version.is_local %}
+ {{ _('web_ui.items.single.package.pinned_repo_local') }}
+ {% else %}
+ {{
+ _('web_ui.items.single.package.pinned_repo_{}')
+ .format(display_info.active_version.info.repo)
+ }}
+ {% endif %}
+ {% else %}
+ {# display_info.frozen == FrozenStatus.EXACT_VERSION #}
+ {% do pin_ver_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single.package.pinned_ver') }}
+ {% endif %}
+ </p>
+
+ {{
+ button_row([
+ (unpin_but_classes, unpin_text, {'action': 'unfreeze_item'}),
+ (pin_repo_but_classes, pin_repo_text, {'action': 'freeze_to_repo'}),
+ (pin_ver_but_classes, pin_ver_text, {'action': 'freeze_to_version'})
+ ])
+ }}
+ {% endif %}{# display_info.enabled == EnabledStatus.ENABLED #}
+{% endblock %}
+
+{% block version_list_heading %}
+ {{ _('web_ui.items.single.package.version_list_heading') }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja
new file mode 100644
index 0000000..386c0c8
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/package_viewversion.html.jinja
@@ -0,0 +1,252 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI package version view 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 "items/item_viewversion.html.jinja" %}
+
+{% block title %}
+ {{ _('web_ui.items.single_version.package.title') }}
+{% endblock %}
+
+{% block heading %}
+ {% if version_display_info.is_local %}
+ {{
+ _('web_ui.items.single_version.package_local.heading.name_{}')
+ .format(version_display_info.info.long_name)
+ }}
+ {% else %}
+ {{
+ _('web_ui.items.single_version.package.heading.name_{}')
+ .format(version_display_info.info.long_name)
+ }}
+ {% endif %}
+{% endblock %}
+
+{% block item_install_failed_msg %}
+ {{ _('web_ui.items.single_version.package.install_failed') }}
+{% endblock %}
+
+{% block item_is_installed_msg %}
+ {{ _('web_ui.items.single_version.package.is_installed') }}
+{% endblock %}
+
+{% block item_is_not_installed_msg %}
+ {{ _('web_ui.items.single_version.package.is_not_installed') }}
+{% endblock %}
+
+{% block version_list_heading %}
+ {{ _('web_ui.items.single_version.package.version_list_heading') }}
+{% endblock %}
+
+{% block main_info_rest %}
+ {{ label(_('web_ui.items.single_version.package.payloads_label')) }}
+
+ {% if version_display_info.info.payloads|length > 0 %}
+ <ul class="item-list has-colored-links">
+ {% set by_lib = {} %}
+ {%
+ for pattern_struct, spec in version_display_info.info.payloads.items()
+ if pattern_struct.orig_url not in processed_patterns
+ %}
+ {% set pattern = pattern_struct.orig_url %}
+ {% do by_lib.setdefault(spec.identifier, []).append(pattern) %}
+ {% endfor %}
+ {% for lib_identifier, patterns in by_lib|dictsort %}
+ <li class="invisible-entry-line">
+ <div>
+ {% if settings.advanced_user %}
+ <div>
+ {% set encoded = patterns[0]|urlencode|replace('/', '%2F') %}
+ {%
+ set url = url_for(
+ '.show_package_library',
+ item_version_id = version_display_info.ref.id,
+ pattern = encoded,
+ lib_identifier = lib_identifier
+ )
+ %}
+ <a href="{{ url }}">
+ {{ lib_identifier }}
+ </a>
+ </div>
+ {% set pattern_div_attrs = {'class': 'small-print'} %}
+ {% endif %}
+ {% for pattern in patterns|unique|sort(attribute='identifier') %}
+ <div{{ pattern_div_attrs|default({})|xmlattr }}>
+ {{ pattern }}
+ </div>
+ {% endfor %}
+ </div>
+ </li>
+ {% endfor %}
+ </ul>
+ {% else %}
+ <p>
+ {{ _('web_ui.items.single_version.package.no_payloads') }}
+ </p>
+ {% endif %}
+
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.items.single_version.package.enabled_label')) }}
+
+ {% set enable_but_classes = ['blue-button'] %}
+ {% set unenable_but_classes = ['green-button'] %}
+ {% set disable_but_classes = ['red-button'] %}
+
+ {% if not version_display_info.info.compatible %}
+ {% do enable_but_classes.append('disabled-button') %}
+ {% endif %}
+
+ {% set unenable_text = _('web_ui.items.unenable_button') %}
+ {% set disable_text = _('web_ui.items.disable_button') %}
+ {% set enable_text = _('web_ui.items.enable_button') %}
+
+ <p>
+ {% if display_info.enabled == EnabledStatus.NO_MARK %}
+ {% do unenable_but_classes.append('disabled-button') %}
+ {%
+ if version_display_info.active == ActiveStatus.NOT_ACTIVE or
+ settings.mapping_use_mode == MappingUseMode.WHEN_ENABLED
+ %}
+ {{ _('web_ui.items.single_version.package.item_not_activated') }}
+ {% elif settings.mapping_use_mode == MappingUseMode.QUESTION %}
+ {{ _('web_ui.items.single_version.package.item_will_be_asked_about') }}
+ {% else %}
+ {# settings.mapping_use_mode == MappingUseMode.AUTO #}
+ {{ _('web_ui.items.single_version.package.item_auto_activated') }}
+ {% endif %}
+ {% elif display_info.enabled == EnabledStatus.DISABLED %}
+ {% do disable_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single_version.package.item_disabled') }}
+ {% else %}
+ {# display_info.enabled == EnabledStatus.ENABLED #}
+ {% do enable_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single_version.package.item_enabled') }}
+ {% endif %}
+ </p>
+
+ {{
+ button_row([
+ (disable_but_classes, disable_text, {'action': 'disable_item'}),
+ (unenable_but_classes, unenable_text, {'action': 'unenable_item'}),
+ (enable_but_classes, enable_text, {'action': 'enable_item_version'})
+ ])
+ }}
+
+ {% if display_info.enabled == EnabledStatus.ENABLED %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.items.single_version.package.pinning_label')) }}
+
+ {% set unpin_but_classes = ['green-button'] %}
+ {% set pin_repo_but_classes = ['green-button'] %}
+ {% set pin_ver_but_classes = ['green-button'] %}
+
+ {% if not version_display_info.info.compatible %}
+ {% do unpin_but_classes.append('disabled-button') %}
+ {% do pin_repo_but_classes.append('disabled-button') %}
+ {% do pin_ver_but_classes.append('disabled-button') %}
+ {% endif %}
+
+ {% set unpin_text = _('web_ui.items.single_version.unpin_button') %}
+
+ <p>
+ {% if display_info.frozen == FrozenStatus.NOT_FROZEN %}
+ {% do unpin_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single_version.not_pinned') }}
+ {% endif %}
+
+ {% if display_info.frozen == FrozenStatus.REPOSITORY %}
+ {% if display_info.active_version.is_local %}
+ {{ _('web_ui.items.single_version.pinned_repo_local') }}
+ {% else %}
+ {{
+ _('web_ui.items.single_version.pinned_repo_{}')
+ .format(display_info.active_version.info.repo)
+ }}
+ {% endif %}
+ {%
+ if display_info.active_version.info.repo ==
+ version_display_info.info.repo
+ %}
+ {% if version_display_info.is_local %}
+ {%
+ set pin_repo_text =
+ _('web_ui.items.single_version.pin_local_repo_button')
+ %}
+ {% else %}
+ {%
+ set pin_repo_text =
+ _('web_ui.items.single_version.pin_repo_button')
+ %}
+ {% endif %}
+ {% do pin_repo_but_classes.append('disabled-button') %}
+ {% else %}
+ {%
+ set pin_repo_text =
+ _('web_ui.items.single_version.repin_repo_button')
+ %}
+ {% endif %}
+ {% else %}{# display_info.frozen == FrozenStatus.REPOSITORY #}
+ {%
+ set pin_repo_text =
+ _('web_ui.items.single_version.pin_repo_button')
+ %}
+ {% endif %}{# else/ display_info.frozen == FrozenStatus.REPOSITORY #}
+
+ {% if display_info.frozen == FrozenStatus.EXACT_VERSION %}
+ {% if display_info.active_version.ref == version_display_info.ref %}
+ {%
+ set pin_ver_text =
+ _('web_ui.items.single_version.pin_ver_button')
+ %}
+ {% do pin_ver_but_classes.append('disabled-button') %}
+ {{ _('web_ui.items.single_version.pinned_ver') }}
+ {% else %}
+ {%
+ set pin_ver_text = _('web_ui.items.single_version.repin_ver_button')
+ %}
+ {{ _('web_ui.items.single_version.pinned_other_ver') }}
+ {% endif %}
+ {% else %}
+ {% set pin_ver_text = _('web_ui.items.single_version.pin_ver_button') %}
+ {% endif %}{# else/ display_info.frozen == FrozenStatus.EXACT_VERSION #}
+
+ {% if display_info.active_version.ref == version_display_info.ref %}
+ {% if display_info.frozen != FrozenStatus.EXACT_VERSION %}
+ {{ _('web_ui.items.single_version.active_ver_is_this_one') }}
+ {% endif %}
+ {% else %}
+ {{
+ _('web_ui.items.single_version.active_ver_is_{}')
+ .format(version_with_repo(display_info.active_version))
+ }}
+ {% endif %}
+ </p>
+
+ {{
+ button_row([
+ (unpin_but_classes, unpin_text, {'action': 'unfreeze_item'}),
+ (pin_repo_but_classes, pin_repo_text, {'action': 'freeze_to_repo'}),
+ (pin_ver_but_classes, pin_ver_text, {'action': 'freeze_to_version'})
+ ])
+ }}
+ {% endif %}{# display_info.enabled == EnabledStatus.ENABLED #}
+{% endblock main_info_rest %}
diff --git a/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja b/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja
new file mode 100644
index 0000000..43acaf7
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/items/packages.html.jinja
@@ -0,0 +1,83 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI package list 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.packages.title') }} {% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/item_list_style.css.jinja' %}
+
+ ul.item-list > li > a {
+ display: flex !important;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 2.2em;
+ }
+{% endblock %}
+
+{% block main %}
+ <h3>
+ {{ _('web_ui.packages.heading') }}
+ {{ hkt_doc_link('packages') }}
+ </h3>
+
+ <ul class="item-list">
+ {% for info in display_infos %}
+ {% set entry_classes = [] %}
+
+ {% if info.enabled == EnabledStatus.ENABLED %}
+ {% do entry_classes.append('entry-line-blue') %}
+ {% elif info.enabled == EnabledStatus.DISABLED %}
+ {% do entry_classes.append('entry-line-red') %}
+ {% elif info.active_version is not none %}
+ {% if info.active_version.active == ActiveStatus.REQUIRED %}
+ {% do entry_classes.append('entry-line-blue') %}
+ {% do entry_classes.append('entry-line-dashed') %}
+ {% elif info.active_version.active == ActiveStatus.AUTO %}
+ {% do entry_classes.append('entry-line-green') %}
+ {% endif %}
+ {% endif %}
+
+ <li class="{{ entry_classes|join(' ') }}">
+ <a href="{{ url_for('.show_package', item_id=info.ref.id) }}">
+ <div>
+ {{ info.identifier }}
+ </div>
+ {%
+ if info.active_version is not none and
+ info.active_version.active == ActiveStatus.REQUIRED
+ %}
+ {% set ver_desc = info.active_version.info.version_string %}
+ {% if not info.active_version.is_local %}
+ {% set repo_name = info.active_version.info.repo %}
+ {% set ver_desc = ver_desc + ' @ ' + repo_name %}
+ {% endif %}
+ <div class="small-print">
+ {{ _('web_ui.packages.enabled_version_{}').format(ver_desc) }}
+ </div>
+ {% endif %}
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+{% 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..9e40ac0
--- /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 "web_ui_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
new file mode 100644
index 0000000..a17e61d
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/prompts/auto_install_error.html.jinja
@@ -0,0 +1,57 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI page that informs about failure of automatic package installation.
+
+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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %}
+ {{ _('web_ui.prompts.auto_install_error.title') }}
+{% endblock %}
+
+{% block main %}
+ {% if file_installation_error is defined %}
+ {{ error_note(_('web_ui.err.retry_install.file_installation_error')) }}
+ {% endif %}
+
+ {% if repo_communication_error is defined %}
+ {{ error_note(_('web_ui.err.retry_install.repo_communication_error')) }}
+ {% endif %}
+
+ <h3>
+ {{ _('web_ui.prompts.auto_install_error.heading') }}
+ </h3>
+
+ <p>
+ {{
+ _('web_ui.prompts.auto_install_error.package_{}_failed_to_install')
+ .format(display_info.mapping_info.info.long_name)
+ }}
+ </p>
+
+ {% set disable_text = _('web_ui.prompts.auto_install_error.disable_button') %}
+ {% set retry_text = _('web_ui.prompts.auto_install_error.retry_button') %}
+
+ {{
+ button_row([
+ (['red-button'], disable_text, {'action': 'disable_mapping'}),
+ (['green-button'], retry_text, {'action': 'retry_install'})
+ ], {'mapping_ver_id': display_info.mapping_info.ref.id}
+ )
+ }}
+{% endblock %}
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
new file mode 100644
index 0000000..2df38b3
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/prompts/package_suggestion.html.jinja
@@ -0,0 +1,58 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI page that asks whether to enable a package that can be used with
+current 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %}
+ {{ _('web_ui.prompts.package_suggestion.title') }}
+{% endblock %}
+
+{% block main %}
+ {% if file_installation_error is defined %}
+ {{ error_note(_('web_ui.err.file_installation_error')) }}
+ {% endif %}
+
+ {% if repo_communication_error is defined %}
+ {{ error_note(_('web_ui.err.repo_communication_error')) }}
+ {% endif %}
+
+ <h3>
+ {{ _('web_ui.prompts.package_suggestion.heading') }}
+ </h3>
+
+ <p>
+ {{
+ _('web_ui.prompts.package_suggestion.do_you_want_to_enable_package_{}')
+ .format(display_info.mapping_info.info.long_name)
+ }}
+ </p>
+
+ {% set disable_text = _('web_ui.prompts.package_suggestion.disable_button') %}
+ {% set enable_text = _('web_ui.prompts.package_suggestion.enable_button') %}
+
+ {{
+ button_row([
+ (['red-button'], disable_text, {'action': 'disable_mapping'}),
+ (['blue-button'], enable_text, {'action': 'enable_mapping'})
+ ], {'mapping_ver_id': display_info.mapping_info.ref.id}
+ )
+ }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja b/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja
new file mode 100644
index 0000000..91c8c0d
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/repos/add.html.jinja
@@ -0,0 +1,53 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI repo creation 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.repos.add.title') }} {% endblock %}
+
+{% block main %}
+ <h3>
+ {{ _('web_ui.repos.add.heading') }}
+ {{ hkt_doc_link('repositories') }}
+ </h3>
+
+ <form method="POST">
+ {{ label(_('web_ui.repos.add.name_field_label'), 'name') }}
+ {% if repo_name_invalid is defined %}
+ {{ error_note(_('web_ui.err.repo_name_invalid')) }}
+ {% endif %}
+ {% if repo_name_taken is defined %}
+ {{ error_note(_('web_ui.err.repo_name_taken')) }}
+ {% endif %}
+ {{ form_field('name') }}
+
+ {{ label(_('web_ui.repos.add.url_field_label'), 'url') }}
+ {% if repo_url_invalid is defined %}
+ {{ error_note(_('web_ui.err.repo_url_invalid')) }}
+ {% endif %}
+ {{ form_field('url') }}
+
+ <div class="flex-row block-with-bottom-margin">
+ <button class="green-button">
+ {{ _('web_ui.repos.add.submit_button') }}
+ </button>
+ </div>
+ </form>
+{% 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
new file mode 100644
index 0000000..0742fc1
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/repos/index.html.jinja
@@ -0,0 +1,90 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI repos list 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %}{{ _('web_ui.repos.title') }}{% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/item_list_style.css.jinja' %}
+{% endblock %}
+
+{% block main %}
+ <h3>
+ {{ _('web_ui.repos.heading') }}
+ {{ hkt_doc_link('repositories') }}
+ </h3>
+
+ <a href="{{ url_for('.add_repo') }}"
+ class="green-button block-with-bottom-margin">
+ {{ _('web_ui.repos.add_repo_button') }}
+ </a>
+
+ <div class="horizontal-separator"></div>
+
+ <h4>{{ _('web_ui.repos.repo_list_heading') }}</h4>
+
+ <ul class="item-list">
+ {% for info in display_infos %}
+ {% set entry_classes = [] %}
+
+ {% if info.deleted %}
+ {% do entry_classes.append('entry-line-red') %}
+ {% else %}
+ {% do entry_classes.append('entry-line-green') %}
+ {% endif %}
+
+ <li class="{{ entry_classes|join(' ') }}">
+ <a href="{{ url_for('.show_repo', repo_id=info.ref.id) }}">
+ <div>
+ {{ info.name }}
+ </div>
+ {% if not info.deleted %}
+ <div class="small-print">
+ {{ info.url }}
+ </div>
+ {% endif %}
+ <div class="small-print">
+ {{ _('web_ui.repos.package_count_{}').format(info.mapping_count) }}
+ </div>
+ </a>
+ </li>
+ {% endfor %}
+ {%
+ if local_semirepo_info.mapping_count > 0 or
+ local_semirepo_info.resource_count > 0
+ %}
+ {% set url = url_for('.show_repo', repo_id=local_semirepo_info.ref.id) %}
+ <li>
+ <a href="{{ url }}">
+ {{ _('web_ui.repos.local_packages_semirepo') }}
+ <div class="small-print">
+ {{
+ _('web_ui.repos.package_count_{}')
+ .format(local_semirepo_info.mapping_count)
+ }}
+ </div>
+ </a>
+ </li>
+ {% endif %}
+ </ul>
+{% 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
new file mode 100644
index 0000000..939b2d6
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/repos/show_single.html.jinja
@@ -0,0 +1,183 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI repository settings 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.repos.single.title') }} {% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/checkbox_tricks_style.css.jinja' %}
+{% endblock %}
+
+{% import 'import/checkbox_tricks.html.jinja' as tricks %}
+
+{% block main %}
+ {% if file_installation_error is defined %}
+ {{ error_note(_('web_ui.err.file_installation_error')) }}
+ {% endif %}
+
+ {% if repo_communication_error is defined %}
+ {{ error_note(_('web_ui.err.repo_communication_error')) }}
+ {% endif %}
+
+ {% if repo_api_version_unsupported is defined %}
+ {{ error_note(_('web_ui.err.repo_api_version_unsupported')) }}
+ {% endif %}
+
+ {% if display_info.is_local_semirepo %}
+ <h3>{{ _('web_ui.repos.local_packages_semirepo') }}</h3>
+ {% else %}
+ <h3>
+ {{ _('web_ui.repos.single.heading.name_{}').format(display_info.name) }}
+ </h3>
+ {% if not display_info.deleted %}
+ {{ label(_('web_ui.repos.single.name_label')) }}
+
+ <p>
+ {{ display_info.name }}
+ </p>
+
+ {% set button_text = _('web_ui.repos.single.update_name_button') %}
+ {% set initial_show = repo_name_invalid is defined %}
+ {% set initial_show = initial_show or repo_name_taken is defined %}
+ {{ tricks.sibling_hider_but(button_text, 'edit_name', initial_show) }}
+
+ <form method="POST">
+ <input type="hidden" name="action" value="update_repo_data">
+
+ {% if repo_name_invalid is defined %}
+ {{ error_note(_('web_ui.err.repo_name_invalid')) }}
+ {% endif %}
+
+ {% if repo_name_taken is defined %}
+ {{ error_note(_('web_ui.err.repo_name_taken')) }}
+ {% endif %}
+
+ <div class="flex-row">
+ <input name="name" value="{{ display_info.name }}" required="">
+ </div>
+
+ <div class="flex-row">
+ <label for="{{ tricks.hider_id('edit_name') }}"
+ class="red-button button-brodering-right">
+ {{ _('web_ui.repos.single.no_update_name_button') }}
+ </label>
+ <div class="button-row-separator"></div>
+ <button class="green-button button-bordering-left">
+ {{ _('web_ui.repos.single.commit_update_name_button') }}
+ </button>
+ </div>
+ </form>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}{# not display_info.deleted #}
+ {% endif %}{# else/ display_info.is_local_semirepo #}
+
+ {% if display_info.deleted and not display_info.is_local_semirepo %}
+ <p>
+ {{ _('web_ui.repos.single.repo_is_deleted') }}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% elif not display_info.deleted %}
+ {{ label(_('web_ui.repos.single.url_label')) }}
+
+ <p>
+ {{ display_info.url }}
+ </p>
+
+ {% set button_text = _('web_ui.repos.single.update_url_button') %}
+ {% set initial_show = repo_url_invalid is defined %}
+ {{ tricks.sibling_hider_but(button_text, 'edit_url', initial_show) }}
+
+ <form method="POST">
+ <input type="hidden" name="action" value="update_repo_data">
+
+ {% if repo_url_invalid is defined %}
+ {{ error_note(_('web_ui.err.repo_url_invalid')) }}
+ {% endif %}
+
+ {{ form_field('url', sep_after=false) }}
+
+ <div class="flex-row">
+ <label for="{{ tricks.hider_id('edit_url') }}"
+ class="red-button button-brodering-right">
+ {{ _('web_ui.repos.single.no_update_url_button') }}
+ </label>
+ <div class="button-row-separator"></div>
+ <button class="green-button button-bordering-left">
+ {{ _('web_ui.repos.single.commit_update_url_button') }}
+ </button>
+ </div>
+ </form>
+
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.repos.single.last_refreshed_label')) }}
+
+ <p>
+ {% if display_info.last_refreshed is none %}
+ {{ _('web_ui.repos.single.repo_never_refreshed') }}
+ {% else %}
+ {{ display_info.last_refreshed.strftime('%F %H:%M') }}
+ {% endif %}
+ </p>
+
+ <div class="horizontal-separator"></div>
+ {% endif %}{# not display_info.deleted (elif) #}
+
+ {{ label(_('web_ui.repos.single.stats_label')) }}
+
+ <p>
+ {% if settings.advanced_user %}
+ {{
+ _('web_ui.repos.item_count_{mappings}_{resources}')
+ .format(
+ mappings = display_info.mapping_count,
+ resources = display_info.resource_count
+ )
+ }}
+ {% else %}
+ {{
+ _('web_ui.repos.item_count_{mappings}')
+ .format(mappings = display_info.mapping_count)
+ }}
+ {% endif %}
+ {{ hkt_doc_link('packages') }}
+ </p>
+
+ {% if not display_info.deleted %}
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.repos.single.actions_label')) }}
+
+ {% set remove_text = _('web_ui.repos.single.remove_button') %}
+ {% set refresh_text = _('web_ui.repos.single.refresh_button') %}
+
+ {{
+ button_row([
+ (['green-button'], refresh_text, {'action': 'refresh_repo'}),
+ (['red-button'], remove_text, {'action': 'remove_repo'})
+ ])
+ }}
+ {% endif %}
+{% 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
new file mode 100644
index 0000000..24ec239
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/rules/add.html.jinja
@@ -0,0 +1,60 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI script blocking/allowing rule creation 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.rules.add.title') }} {% endblock %}
+
+{% block main %}
+ <h3>
+ {{ _('web_ui.rules.add.heading') }}
+ {{ hkt_doc_link('script_blocking') }}
+ </h3>
+
+ <form method="POST" action="{{ url_for('.add_rule') }}">
+ {{ label(_('web_ui.rules.add.pattern_field_label'), 'pattern') }}
+
+ {% if rule_pattern_invalid is defined %}
+ {{ error_note(_('web_ui.err.rule_pattern_invalid')) }}
+ {% endif %}
+
+ {{ form_field('pattern', initial_value=pattern|default(none)) }}
+
+ {{ label(_('web_ui.rules.add.block_or_allow_label'), 'allow') }}
+
+ <div class="block-with-bottom-margin">
+ <input id="block_box" name="allow" type="radio" value="false" checked="">
+ <label for="block_box"> {{ _('web_ui.rules.add.block_label') }} </label>
+ </div>
+
+ <div class="block-with-bottom-margin">
+ <input id="allow_box" name="allow" type="radio" value="true">
+ <label for="allow_box"> {{ _('web_ui.rules.add.allow_label') }} </label>
+ </div>
+
+ <div class="horizontal-separator"></div>
+
+ <div class="flex-row block-with-bottom-margin">
+ <button class="green-button">
+ {{ _('web_ui.rules.add.submit_button') }}
+ </button>
+ </div>
+ </form>
+{% 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
new file mode 100644
index 0000000..d5d1d07
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/rules/index.html.jinja
@@ -0,0 +1,64 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI script allowing/blocking rule list 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %}{{ _('web_ui.rules.title') }}{% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/item_list_style.css.jinja' %}
+{% endblock %}
+
+{% block main %}
+ <h3>
+ {{ _('web_ui.rules.heading') }}
+ {{ hkt_doc_link('script_blocking') }}
+ </h3>
+
+ <a href="{{ url_for('.add_rule') }}"
+ class="green-button block-with-bottom-margin">
+ {{ _('web_ui.rules.add_rule_button') }}
+ </a>
+
+ <div class="horizontal-separator"></div>
+
+ <h4>{{ _('web_ui.rules.rule_list_heading') }}</h4>
+
+ <ul class="item-list">
+ {% for info in display_infos %}
+
+ {% if info.allow_scripts %}
+ {% set entry_classes = ['entry-line-red'] %}
+ {% else %}
+ {% set entry_classes = ['entry-line-blue'] %}
+ {% endif %}
+
+ <li class="{{ entry_classes|join(' ') }}">
+ <a href="{{ url_for('.show_rule', rule_id=info.ref.id) }}">
+ <div>
+ {{ info.pattern }}
+ </div>
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+{% 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
new file mode 100644
index 0000000..7d29a0d
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/rules/show_single.html.jinja
@@ -0,0 +1,106 @@
+{#
+SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+Proxy web UI script allowing/blocking rule modification 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 "hkt_mitm_it_base.html.jinja" %}
+
+{% block title %} {{ _('web_ui.rules.single.title') }} {% endblock %}
+
+{% block style %}
+ {{ super() }}
+
+ {% include 'include/checkbox_tricks_style.css.jinja' %}
+{% endblock %}
+
+{% import 'import/checkbox_tricks.html.jinja' as tricks %}
+
+{% block main %}
+ <h3>
+ {% if display_info.allow_scripts %}
+ {{ _('web_ui.rules.single.heading.allow') }}
+ {% else %}
+ {{ _('web_ui.rules.single.heading.block') }}
+ {% endif %}
+ </h3>
+
+ {{ label(_('web_ui.rules.single.pattern_label')) }}
+
+ <p>
+ {{ display_info.pattern }}
+ </p>
+
+ {% set button_text = _('web_ui.rules.single.update_pattern_button') %}
+ {% set initial_show = rule_pattern_invalid is defined %}
+ {{ tricks.sibling_hider_but(button_text, 'edit_pattern', initial_show) }}
+
+ <form method="POST">
+ <input type="hidden" name="action" value="update_rule_data">
+
+ {% if rule_pattern_invalid is defined %}
+ {{ error_note(_('web_ui.err.rule_pattern_invalid')) }}
+ {% endif %}
+
+ <div class="flex-row">
+ <input name="pattern" value="{{ display_info.pattern }}" required="">
+ </div>
+
+ <div class="flex-row">
+ <label for="{{ tricks.hider_id('edit_pattern') }}"
+ class="red-button button-bordering-right">
+ {{ _('web_ui.rules.single.no_update_pattern_button') }}
+ </label>
+ <div class="button-row-separator"></div>
+ <button class="green-button button-bordering-left">
+ {{ _('web_ui.rules.single.commit_update_pattern_button') }}
+ </button>
+ </div>
+ </form>
+
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.rules.single.block_or_allow_label')) }}
+
+ {% set allow_but_classes = ['red-button'] %}
+ {% set block_but_classes = ['blue-button'] %}
+
+ {% set allow_text = _('web_ui.rules.single.allow_button') %}
+ {% set block_text = _('web_ui.rules.single.block_button') %}
+
+ {% if display_info.allow_scripts %}
+ {% do allow_but_classes.append('disabled-button') %}
+ {% else %}
+ {% do block_but_classes.append('disabled-button') %}
+ {% endif %}
+
+ {{
+ button_row([
+ (allow_but_classes, allow_text, {'allow': 'true'}),
+ (block_but_classes, block_text, {'allow': 'false'})
+ ], {'action': 'update_rule_data'}
+ )
+ }}
+
+ <div class="horizontal-separator"></div>
+
+ {{ label(_('web_ui.rules.single.actions_label')) }}
+
+ {% set button_text = _('web_ui.rules.single.remove_button') %}
+ {% set extra_fields = {'action': 'remove_rule'} %}
+ {{ button_row([(['green-button'], button_text, extra_fields)]) }}
+{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/web_ui_base.html.jinja b/src/hydrilla/proxy/web_ui/templates/web_ui_base.html.jinja
new file mode 100644
index 0000000..0d5d582
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/web_ui_base.html.jinja
@@ -0,0 +1,22 @@
+{#
+SPDX-License-Identifier: CC0-1.0
+
+Proxy web UI base page template.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+
+Available under the terms of Creative Commons Zero v1.0 Universal.
+#}
+{% extends "base.html.jinja" %}
+
+{% block head %}
+ {{ super() }}
+
+ <title>
+ {% block title required %}{% endblock %}
+ -
+ {{ _('web_ui.base.title.haketilo_proxy') }}
+ </title>
+{% endblock head %}
diff --git a/src/hydrilla/py.typed b/src/hydrilla/py.typed
new file mode 100644
index 0000000..f41d511
--- /dev/null
+++ b/src/hydrilla/py.typed
@@ -0,0 +1,5 @@
+SPDX-License-Identifier: CC0-1.0
+
+Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
+
+Available under the terms of Creative Commons Zero v1.0 Universal.
diff --git a/src/hydrilla/schemas/1.x b/src/hydrilla/schemas/1.x
new file mode 160000
+Subproject 09634f3446866f712a022327683b1149d8f46bf
diff --git a/src/hydrilla/schemas/2.x b/src/hydrilla/schemas/2.x
new file mode 160000
+Subproject d94ef4544faac662f49bed41700c9010804b245
diff --git a/src/hydrilla/server/config.json b/src/hydrilla/server/config.json
index bde341c..e307548 100644
--- a/src/hydrilla/server/config.json
+++ b/src/hydrilla/server/config.json
@@ -28,9 +28,6 @@
// What port to listen on (if not being run through WSGI).
"port": 10112,
- // What localization to use for console messages and served HTML files.
- "language": "en_US",
-
// Whether to exit upon emitting a warning.
"werror": false
}
diff --git a/src/hydrilla/server/config.py b/src/hydrilla/server/config.py
index 1edd070..42aabab 100644
--- a/src/hydrilla/server/config.py
+++ b/src/hydrilla/server/config.py
@@ -21,19 +21,20 @@
#
#
# I, Wojtek Kosior, thereby promise not to sue for violation of this
-# file's license. Although I request that you do not make use this code
-# in a proprietary program, I am not going to enforce this in court.
-
-# Enable using with Python 3.7.
-from __future__ import annotations
+# 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.
import json
+import typing as t
from pathlib import Path
-import jsonschema
+import jsonschema # type: ignore
-from .. import util
+from ..translations import smart_gettext as _
+from ..exceptions import HaketiloException
+from .. import json_instances
config_schema = {
'$schema': 'http://json-schema.org/draft-07/schema#',
@@ -42,9 +43,6 @@ config_schema = {
'malcontent_dir': {
'type': 'string'
},
- 'malcontent_dir': {
- 'type': 'string'
- },
'hydrilla_project_url': {
'type': 'string'
},
@@ -67,15 +65,18 @@ config_schema = {
},
'werror': {
'type': 'boolean'
+ },
+ 'verify_files': {
+ 'type': 'boolean'
}
}
}
here = Path(__file__).resolve().parent
-def load(config_paths: list[Path]=[here / 'config.json'],
- can_fail: list[bool]=[]) -> dict:
- config = {}
+def load(config_paths: t.List[Path]=[here / 'config.json'],
+ can_fail: t.List[bool]=[]) -> t.Dict[str, t.Any]:
+ config: t.Dict[str, t.Any] = {}
bools_missing = max(0, len(config_paths) - len(can_fail))
config_paths = [*config_paths]
@@ -92,17 +93,13 @@ def load(config_paths: list[Path]=[here / 'config.json'],
continue
raise e from None
- new_config = json.loads(util.strip_json_comments(json_text))
+ new_config = json.loads(json_instances.strip_json_comments(json_text))
jsonschema.validate(new_config, config_schema)
config.update(new_config)
- if 'malcontent_dir' in config:
- malcontent_dir = Path(config['malcontent_dir'])
- if not malcontent_dir.is_absolute():
- malcontent_dir = path.parent / malcontent_dir
-
- config['malcontent_dir'] = str(malcontent_dir.resolve())
+ if 'malcontent_dir' in new_config:
+ malcontent_path_relative_to = path.parent
for key, failure_ok in [('try_configs', True), ('use_configs', False)]:
paths = new_config.get(key, [])
@@ -110,6 +107,12 @@ def load(config_paths: list[Path]=[here / 'config.json'],
config_paths.extend(paths)
can_fail.extend([failure_ok] * len(paths))
+
+ if 'malcontent_dir' in config:
+ malcontent_dir_str = config['malcontent_dir']
+ malcontent_dir_path = malcontent_path_relative_to / malcontent_dir_str
+ config['malcontent_dir'] = str(malcontent_dir_path)
+
for key in ('try_configs', 'use_configs'):
if key in config:
config.pop(key)
diff --git a/src/hydrilla/server/locales/en_US/LC_MESSAGES/hydrilla-messages.po b/src/hydrilla/server/locales/en_US/LC_MESSAGES/hydrilla-messages.po
deleted file mode 100644
index 7ea930a..0000000
--- a/src/hydrilla/server/locales/en_US/LC_MESSAGES/hydrilla-messages.po
+++ /dev/null
@@ -1,147 +0,0 @@
-# SPDX-License-Identifier: CC0-1.0
-#
-# English (United States) translations for hydrilla.
-# Copyright (C) 2021, 2022 Wojtek Kosior <koszko@koszko.org>
-# Available under the terms of Creative Commons Zero v1.0 Universal.
-msgid ""
-msgstr ""
-"Project-Id-Version: hydrilla.builder 0.1\n"
-"Report-Msgid-Bugs-To: koszko@koszko.org\n"
-"POT-Creation-Date: 2022-04-22 17:09+0200\n"
-"PO-Revision-Date: 2022-02-12 00:00+0000\n"
-"Last-Translator: Wojtek Kosior <koszko@koszko.org>\n"
-"Language: en_US\n"
-"Language-Team: en_US <koszko@koszko.org>\n"
-"Plural-Forms: nplurals=2; plural=(n != 1)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=utf-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.8.0\n"
-
-#: src/hydrilla/server/serve.py:122
-#, python-brace-format
-msgid "uuid_mismatch_{identifier}"
-msgstr "Two different uuids were specified for item '{identifier}'."
-
-#: src/hydrilla/server/serve.py:129
-#, python-brace-format
-msgid "version_clash_{identifier}_{version}"
-msgstr "Version '{version}' specified more than once for item '{identifier}'."
-
-#: src/hydrilla/server/serve.py:245 src/hydrilla/server/serve.py:257
-msgid "invalid_URL_{}"
-msgstr "Invalid URL/pattern: '{}'."
-
-#: src/hydrilla/server/serve.py:249
-msgid "disallowed_protocol_{}"
-msgstr "Disallowed protocol: '{}'."
-
-#: src/hydrilla/server/serve.py:302
-msgid "malcontent_dir_path_not_dir_{}"
-msgstr "Provided 'malcontent_dir' path does not name a directory: {}"
-
-#: src/hydrilla/server/serve.py:321
-msgid "couldnt_load_item_from_{}"
-msgstr "Couldn't load item from {}."
-
-#: src/hydrilla/server/serve.py:347
-msgid "item_{item}_in_file_{file}"
-msgstr "Item {item} incorrectly present under {file}."
-
-#: src/hydrilla/server/serve.py:353
-msgid "item_version_{ver}_in_file_{file}"
-msgstr "Item version {ver} incorrectly present under {file}."
-
-#: src/hydrilla/server/serve.py:376
-msgid "no_dep_{resource}_{ver}_{dep}"
-msgstr "Unknown dependency '{dep}' of resource '{resource}', version '{ver}'."
-
-#: src/hydrilla/server/serve.py:387
-msgid "no_payload_{mapping}_{ver}_{payload}"
-msgstr "Unknown payload '{payload}' of mapping '{mapping}', version '{ver}'."
-
-#: src/hydrilla/server/serve.py:413
-msgid "couldnt_register_{mapping}_{ver}_{pattern}"
-msgstr ""
-"Couldn't register mapping '{mapping}', version '{ver}' (pattern "
-"'{pattern}')."
-
-#: src/hydrilla/server/serve.py:566 src/hydrilla/server/serve.py:588
-#: src/hydrilla/server/serve.py:626
-#, python-format
-msgid "%(prog)s_%(version)s_license"
-msgstr ""
-"%(prog)s %(version)s\n"
-"Copyright (C) 2021,2022 Wojtek Kosior and contributors.\n"
-"License GPLv3+: GNU AGPL version 3 or later "
-"<https://gnu.org/licenses/gpl.html>\n"
-"This is free software: you are free to change and redistribute it.\n"
-"There is NO WARRANTY, to the extent permitted by law."
-
-#: src/hydrilla/server/serve.py:577
-msgid "directory_to_serve_from_overrides_config"
-msgstr ""
-"Directory to serve files from. Overrides value from the config file (if "
-"any)."
-
-#: src/hydrilla/server/serve.py:579
-msgid "project_url_to_display_overrides_config"
-msgstr ""
-"Project url to display on generated HTML pages. Overrides value from the "
-"config file (if any)."
-
-#: src/hydrilla/server/serve.py:581
-msgid "tcp_port_to_listen_on_overrides_config"
-msgstr ""
-"TCP port number to listen on (0-65535). Overrides value from the config "
-"file (if any)."
-
-#: src/hydrilla/server/serve.py:584
-msgid "path_to_config_file_explain_default"
-msgstr ""
-"Path to Hydrilla server configuration file (optional, by default Hydrilla"
-" loads its own config file, which in turn tries to load "
-"/etc/hydrilla/config.json)."
-
-#: src/hydrilla/server/serve.py:586
-msgid "language_to_use_overrides_config"
-msgstr ""
-"Language to use (also affects served HTML files). Overrides value from "
-"the config file (if any)."
-
-#: src/hydrilla/server/serve.py:589 src/hydrilla/server/serve.py:627
-msgid "version_printing"
-msgstr "Print version information and exit."
-
-#: src/hydrilla/server/serve.py:617
-msgid "config_option_{}_not_supplied"
-msgstr "Missing configuration option '{}'."
-
-#: src/hydrilla/server/serve.py:621
-msgid "serve_hydrilla_packages_explain_wsgi_considerations"
-msgstr ""
-"Serve Hydrilla packages.\n"
-"\n"
-"This command is meant to be a quick way to run a local or development "
-"Hydrilla instance. For better performance, consider deployment using "
-"WSGI."
-
-#: src/hydrilla/server/serve.py:632
-msgid "serve_hydrilla_packages_wsgi_help"
-msgstr ""
-"Serve Hydrilla packages.\n"
-"\n"
-"This program is a WSGI script that runs Hydrilla repository behind an "
-"HTTP server like Apache2 or Nginx. You can configure Hydrilla through the"
-" /etc/hydrilla/config.json file."
-
-#. 'hydrilla' as a title
-#: src/hydrilla/server/templates/base.html:99
-#: src/hydrilla/server/templates/base.html:105
-msgid "hydrilla"
-msgstr "Hydrilla"
-
-#: src/hydrilla/server/templates/index.html:29
-msgid "hydrilla_welcome"
-msgstr "Welcome to Hydrilla!"
-
diff --git a/src/hydrilla/server/malcontent.py b/src/hydrilla/server/malcontent.py
new file mode 100644
index 0000000..9bdf6dc
--- /dev/null
+++ b/src/hydrilla/server/malcontent.py
@@ -0,0 +1,252 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# Processing of repository packages.
+#
+# This file is part of Hydrilla
+#
+# Copyright (C) 2021, 2022 Wojtek Kosior
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero 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.
+
+import logging
+import dataclasses as dc
+import typing as t
+
+from pathlib import Path
+
+from immutables import Map
+
+from ..translations import smart_gettext as _
+from ..exceptions import HaketiloException
+from .. import versions
+from .. import item_infos
+from .. import pattern_tree
+
+
+MappingTree = pattern_tree.PatternTree[item_infos.MappingInfo]
+
+# VersionedType = t.TypeVar(
+# 'VersionedType',
+# item_infos.ResourceInfo,
+# item_infos.MappingInfo
+# )
+
+class Malcontent:
+ """
+ Represent a directory with files that can be loaded and served by Hydrilla.
+ """
+ def __init__(
+ self,
+ malcontent_dir_path: Path,
+ werror: bool,
+ verify_files: bool
+ ):
+ """
+ When an instance of Malcontent is constructed, it searches
+ malcontent_dir_path for serveable site-modifying packages and loads
+ them into its data structures.
+ """
+ self.werror: bool = werror
+ self.verify_files: bool = verify_files
+
+ self.resource_infos: item_infos.VersionedResourceInfoMap = Map()
+ self.mapping_infos: item_infos.VersionedMappingInfoMap = Map()
+
+ self.mapping_tree: MappingTree = MappingTree()
+
+ self.malcontent_dir_path = malcontent_dir_path
+
+ if not self.malcontent_dir_path.is_dir():
+ fmt = _('err.server.malcontent_path_not_dir_{}')
+ raise HaketiloException(fmt.format(malcontent_dir_path))
+
+ for type in [item_infos.ItemType.RESOURCE, item_infos.ItemType.MAPPING]:
+ type_path = self.malcontent_dir_path / type.value
+ if not type_path.is_dir():
+ continue
+
+ for subpath in type_path.iterdir():
+ if not subpath.is_dir():
+ continue
+
+ for ver_file in subpath.iterdir():
+ try:
+ self._load_item(type, ver_file)
+ except:
+ if self.werror:
+ raise
+
+ fmt = _('err.server.couldnt_load_item_from_{}')
+ logging.error(fmt.format(ver_file), exc_info=True)
+
+ self._report_missing()
+ self._finalize()
+
+ def _check_package_files(self, info: item_infos.AnyInfo) -> None:
+ by_sha256_dir = self.malcontent_dir_path / 'file' / 'sha256'
+
+ for file_spec in info.files:
+ if (by_sha256_dir / file_spec.sha256).is_file():
+ continue
+
+ fmt = _('err.server.no_file_{required_by}_{ver}_{file}_{sha256}')
+ msg = fmt.format(
+ required_by = info.identifier,
+ ver = versions.version_string(info.version),
+ file = file_spec.name,
+ sha256 = file_spec.sha256
+ )
+ if (self.werror):
+ raise HaketiloException(msg)
+ else:
+ logging.error(msg)
+
+ def _load_item(self, type: item_infos.ItemType, ver_file: Path) \
+ -> None:
+ """
+ Reads, validates and autocompletes serveable mapping/resource
+ definition, then registers information from it in data structures.
+ """
+ version = versions.parse(ver_file.name)
+ identifier = ver_file.parent.name
+
+ item_info = type.info_class.load(ver_file)
+
+ if item_info.identifier != identifier:
+ fmt = _('err.server.item_{item}_in_file_{file}')
+ msg = fmt.format({'item': item_info.identifier, 'file': ver_file})
+ raise HaketiloException(msg)
+
+ if item_info.version != version:
+ ver_str = versions.version_string(item_info.version)
+ fmt = _('item_version_{ver}_in_file_{file}')
+ msg = fmt.format({'ver': ver_str, 'file': ver_file})
+ raise HaketiloException(msg)
+
+ if self.verify_files:
+ self._check_package_files(item_info)
+
+ if isinstance(item_info, item_infos.ResourceInfo):
+ self.resource_infos = item_infos.register_in_versioned_map(
+ map = self.resource_infos,
+ info = item_info
+ )
+ else:
+ self.mapping_infos = item_infos.register_in_versioned_map(
+ map = self.mapping_infos,
+ info = item_info
+ )
+
+ def _report_missing(self) -> None:
+ """
+ Use logger to print information about items that are referenced but
+ were not loaded.
+ """
+ def report_missing_dependency(
+ info: item_infos.ResourceInfo,
+ dep: str
+ ) -> None:
+ msg = _('err.server.no_dep_{resource}_{ver}_{dep}')\
+ .format(dep=dep, resource=info.identifier,
+ ver=versions.version_string(info.version))
+ logging.error(msg)
+
+ for resource_info in item_infos.all_map_infos(self.resource_infos):
+ for dep_specifier in resource_info.dependencies:
+ identifier = dep_specifier.identifier
+ if identifier not in self.resource_infos:
+ report_missing_dependency(resource_info, identifier)
+
+ def report_missing_payload(
+ info: item_infos.MappingInfo,
+ payload: str
+ ) -> None:
+ msg = _('err.server.no_payload_{mapping}_{ver}_{payload}')\
+ .format(mapping=info.identifier, payload=payload,
+ ver=versions.version_string(info.version))
+ logging.error(msg)
+
+ for mapping_info in item_infos.all_map_infos(self.mapping_infos):
+ for resource_specifier in mapping_info.payloads.values():
+ identifier = resource_specifier.identifier
+ if identifier not in self.resource_infos:
+ report_missing_payload(mapping_info, identifier)
+
+ def report_missing_mapping(
+ info: item_infos.AnyInfo,
+ required: str
+ ) -> None:
+ msg = _('err.server.no_mapping_{required_by}_{ver}_{required}')\
+ .format(required_by=info.identifier, required=required,
+ ver=versions.version_string(info.version))
+ logging.error(msg)
+
+ infos: t.Iterable[item_infos.AnyInfo] = (
+ *item_infos.all_map_infos(self.mapping_infos),
+ *item_infos.all_map_infos(self.resource_infos)
+ )
+ for item_info in infos:
+ for mapping_specifier in item_info.required_mappings:
+ identifier = mapping_specifier.identifier
+ if identifier not in self.mapping_infos:
+ report_missing_mapping(item_info, identifier)
+
+ def _finalize(self):
+ """
+ Initialize structures needed to serve queries. Called once after all
+ data gets loaded.
+ """
+ for info in item_infos.all_map_infos(self.mapping_infos):
+ for pattern in info.payloads:
+ try:
+ self.mapping_tree = \
+ self.mapping_tree.register(pattern, info)
+ except:
+ if self.werror:
+ raise
+ msg = _('server.err.couldnt_register_{mapping}_{ver}_{pattern}')\
+ .format(mapping=info.identifier, pattern=pattern,
+ ver=util.version_string(info.version))
+ logging.error(msg)
+
+ def query(self, url: str) -> t.Sequence[item_infos.MappingInfo]:
+ """
+ Return a list of registered mappings that match url.
+
+ If multiple versions of a mapping are applicable, only the most recent
+ is included in the result.
+ """
+ collected: t.Dict[str, item_infos.MappingInfo] = {}
+ for result_set in self.mapping_tree.search(url):
+ for wrapped_mapping_info in result_set:
+ info = wrapped_mapping_info.item
+ previous = collected.get(info.identifier)
+ if previous and previous.version > info.version:
+ continue
+
+ collected[info.identifier] = info
+
+ return list(collected.values())
+
+ def get_all_resources(self) -> t.Sequence[item_infos.ResourceInfo]:
+ return tuple(item_infos.all_map_infos(self.resource_infos))
+
+ def get_all_mappings(self) -> t.Sequence[item_infos.MappingInfo]:
+ return tuple(item_infos.all_map_infos(self.mapping_infos))
diff --git a/src/hydrilla/server/serve.py b/src/hydrilla/server/serve.py
index a6a1204..68dde7a 100644
--- a/src/hydrilla/server/serve.py
+++ b/src/hydrilla/server/serve.py
@@ -21,429 +21,35 @@
#
#
# I, Wojtek Kosior, thereby promise not to sue for violation of this
-# file's license. Although I request that you do not make use this code
-# in a proprietary program, I am not going to enforce this in court.
-
-# Enable using with Python 3.7.
-from __future__ import annotations
+# 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.
import re
import os
-import pathlib
import json
-import logging
+import typing as t
from pathlib import Path
-from hashlib import sha256
-from abc import ABC, abstractmethod
-from typing import Optional, Union, Iterable
import click
import flask
+import werkzeug
-from werkzeug import Response
-
-from .. import util
+from ..exceptions import HaketiloException
+from .. import _version
+from ..translations import smart_gettext as _, translation as make_translation
+from .. import versions
+from .. import item_infos
from . import config
-from . import _version
+from . import malcontent
-here = Path(__file__).resolve().parent
generated_by = {
'name': 'hydrilla.server',
'version': _version.version
}
-class ItemInfo(ABC):
- """Shortened data of a resource/mapping."""
- def __init__(self, item_obj: dict):
- """Initialize ItemInfo using item definition read from JSON."""
- self.version = util.normalize_version(item_obj['version'])
- self.identifier = item_obj['identifier']
- self.uuid = item_obj.get('uuid')
- self.long_name = item_obj['long_name']
-
- def path(self) -> str:
- """
- Get a relative path to this item's JSON definition with respect to
- directory containing items of this type.
- """
- return f'{self.identifier}/{util.version_string(self.version)}'
-
-class ResourceInfo(ItemInfo):
- """Shortened data of a resource."""
- def __init__(self, resource_obj: dict):
- """Initialize ResourceInfo using resource definition read from JSON."""
- super().__init__(resource_obj)
-
- dependencies = resource_obj.get('dependencies', [])
- self.dependencies = [res_ref['identifier'] for res_ref in dependencies]
-
-class MappingInfo(ItemInfo):
- """Shortened data of a mapping."""
- def __init__(self, mapping_obj: dict):
- """Initialize MappingInfo using mapping definition read from JSON."""
- super().__init__(mapping_obj)
-
- self.payloads = {}
- for pattern, res_ref in mapping_obj.get('payloads', {}).items():
- self.payloads[pattern] = res_ref['identifier']
-
- def as_query_result(self) -> str:
- """
- Produce a json.dump()-able object describing this mapping as one of a
- collection of query results.
- """
- return {
- 'version': self.version,
- 'identifier': self.identifier,
- 'long_name': self.long_name
- }
-
-class VersionedItemInfo:
- """Stores data of multiple versions of given resource/mapping."""
- def __init__(self):
- self.uuid = None
- self.identifier = None
- self.by_version = {}
- self.known_versions = []
-
- def register(self, item_info: ItemInfo) -> None:
- """
- Make item info queryable by version. Perform sanity checks for uuid.
- """
- if self.identifier is None:
- self.identifier = item_info.identifier
-
- if self.uuid is None:
- self.uuid = item_info.uuid
-
- if self.uuid is not None and self.uuid != item_info.uuid:
- raise ValueError(f_('uuid_mismatch_{identifier}')
- .format(identifier=self.identifier))
-
- ver = item_info.version
- ver_str = util.version_string(ver)
-
- if ver_str in self.by_version:
- raise ValueError(f_('version_clash_{identifier}_{version}')
- .format(identifier=self.identifier,
- version=ver_str))
-
- self.by_version[ver_str] = item_info
- self.known_versions.append(ver)
-
- def get_by_ver(self, ver: Optional[list[int]]=None) -> Optional[ItemInfo]:
- """
- Find and return info of the newest version of item.
-
- If ver is specified, instead find and return info of that version of the
- item (or None if absent).
- """
- ver = util.version_string(ver or self.known_versions[-1])
-
- return self.by_version.get(ver)
-
- def get_all(self) -> list[ItemInfo]:
- """
- Return a list of item info for all its versions, from oldest ot newest.
- """
- return [self.by_version[util.version_string(ver)]
- for ver in self.known_versions]
-
-class PatternTreeNode:
- """
- "Pattern Tree" is how we refer to the data structure used for querying
- Haketilo patterns. Those look like 'https://*.example.com/ab/***'. The goal
- is to make it possible for given URL to quickly retrieve all known patterns
- that match it.
- """
- def __init__(self):
- self.wildcard_matches = [None, None, None]
- self.literal_match = None
- self.children = {}
-
- def search(self, segments):
- """
- Yields all matches of this segments sequence against the tree that
- starts at this node. Results are produces in order from greatest to
- lowest pattern specificity.
- """
- nodes = [self]
-
- for segment in segments:
- next_node = nodes[-1].children.get(segment)
- if next_node is None:
- break
-
- nodes.append(next_node)
-
- nsegments = len(segments)
- cond_literal = lambda: len(nodes) == nsegments
- cond_wildcard = [
- lambda: len(nodes) + 1 == nsegments and segments[-1] != '*',
- lambda: len(nodes) + 1 < nsegments,
- lambda: len(nodes) + 1 != nsegments or segments[-1] != '***'
- ]
-
- while nodes:
- node = nodes.pop()
-
- for item, condition in [(node.literal_match, cond_literal),
- *zip(node.wildcard_matches, cond_wildcard)]:
- if item is not None and condition():
- yield item
-
- def add(self, segments, item_instantiator):
- """
- Make item queryable through (this branch of) the Pattern Tree. If there
- was not yet any item associated with the tree path designated by
- segments, create a new one using item_instantiator() function. Return
- all items matching this path (both the ones that existed and the ones
- just created).
- """
- node = self
- segment = None
-
- for segment in segments:
- wildcards = node.wildcard_matches
-
- child = node.children.get(segment) or PatternTreeNode()
- node.children[segment] = child
- node = child
-
- if node.literal_match is None:
- node.literal_match = item_instantiator()
-
- if segment not in ('*', '**', '***'):
- return [node.literal_match]
-
- if wildcards[len(segment) - 1] is None:
- wildcards[len(segment) - 1] = item_instantiator()
-
- return [node.literal_match, wildcards[len(segment) - 1]]
-
-proto_regex = re.compile(r'^(?P<proto>\w+)://(?P<rest>.*)$')
-user_re = r'[^/?#@]+@' # r'(?P<user>[^/?#@]+)@' # discarded for now
-query_re = r'\??[^#]*' # r'\??(?P<query>[^#]*)' # discarded for now
-domain_re = r'(?P<domain>[^/?#]+)'
-path_re = r'(?P<path>[^?#]*)'
-http_regex = re.compile(f'{domain_re}{path_re}{query_re}.*')
-ftp_regex = re.compile(f'(?:{user_re})?{domain_re}{path_re}.*')
-
-class UrlError(ValueError):
- """Used to report a URL or URL pattern that is invalid or unsupported."""
- pass
-
-class DeconstructedUrl:
- """Represents a deconstructed URL or URL pattern"""
- def __init__(self, url):
- self.url = url
-
- match = proto_regex.match(url)
- if not match:
- raise UrlError(f_('invalid_URL_{}').format(url))
-
- self.proto = match.group('proto')
- if self.proto not in ('http', 'https', 'ftp'):
- raise UrlError(f_('disallowed_protocol_{}').format(proto))
-
- if self.proto == 'ftp':
- match = ftp_regex.match(match.group('rest'))
- elif self.proto in ('http', 'https'):
- match = http_regex.match(match.group('rest'))
-
- if not match:
- raise UrlError(f_('invalid_URL_{}').format(url))
-
- self.domain = match.group('domain').split('.')
- self.domain.reverse()
- self.path = [*filter(None, match.group('path').split('/'))]
-
-class PatternMapping:
- """
- A mapping info, together with one of its patterns, as stored in Pattern
- Tree.
- """
- def __init__(self, pattern: str, mapping_info: MappingInfo):
- self.pattern = pattern
- self.mapping_info = mapping_info
-
- def register(self, pattern_tree: dict):
- """
- Make self queryable through the Pattern Tree passed in the argument.
- """
- deco = DeconstructedUrl(self.pattern)
-
- domain_tree = pattern_tree.get(deco.proto) or PatternTreeNode()
- pattern_tree[deco.proto] = domain_tree
-
- for path_tree in domain_tree.add(deco.domain, PatternTreeNode):
- for match_list in path_tree.add(deco.path, list):
- match_list.append(self)
-
-class Malcontent:
- """
- Instance of this class represents a directory with files that can be loaded
- and served by Hydrilla.
- """
- def __init__(self, malcontent_dir_path: Path):
- """
- When an instance of Malcontent is constructed, it searches
- malcontent_dir_path for serveable site-modifying packages and loads
- them into its data structures.
- """
- self.infos = {'resource': {}, 'mapping': {}}
- self.pattern_tree = {}
-
- self.malcontent_dir_path = malcontent_dir_path
-
- if not self.malcontent_dir_path.is_dir():
- raise ValueError(f_('malcontent_dir_path_not_dir_{}')
- .format(malcontent_dir_path))
-
- for item_type in ('mapping', 'resource'):
- type_path = self.malcontent_dir_path / item_type
- if not type_path.is_dir():
- continue
-
- for subpath in type_path.iterdir():
- if not subpath.is_dir():
- continue
-
- for ver_file in subpath.iterdir():
- try:
- self._load_item(item_type, ver_file)
- except Exception as e:
- if flask.current_app._hydrilla_werror:
- raise e from None
-
- msg = f_('couldnt_load_item_from_{}').format(ver_file)
- logging.error(msg, exc_info=True)
-
- self._report_missing()
- self._finalize()
-
- def _load_item(self, item_type: str, ver_file: Path) -> None:
- """
- Reads, validates and autocompletes serveable mapping/resource
- definition, then registers information from it in data structures.
- """
- version = util.parse_version(ver_file.name)
- identifier = ver_file.parent.name
-
- with open(ver_file, 'rt') as file_handle:
- item_json = json.load(file_handle)
-
- util.validator_for(f'api_{item_type}_description-1.0.1.schema.json')\
- .validate(item_json)
-
- if item_type == 'resource':
- item_info = ResourceInfo(item_json)
- else:
- item_info = MappingInfo(item_json)
-
- if item_info.identifier != identifier:
- msg = f_('item_{item}_in_file_{file}')\
- .format({'item': item_info.identifier, 'file': ver_file})
- raise ValueError(msg)
-
- if item_info.version != version:
- ver_str = util.version_string(item_info.version)
- msg = f_('item_version_{ver}_in_file_{file}')\
- .format({'ver': ver_str, 'file': ver_file})
- raise ValueError(msg)
-
- versioned_info = self.infos[item_type].get(identifier)
- if versioned_info is None:
- versioned_info = VersionedItemInfo()
- self.infos[item_type][identifier] = versioned_info
-
- versioned_info.register(item_info)
-
- def _all_of_type(self, item_type: str) -> Iterable[ItemInfo]:
- """Iterator over all registered versions of all mappings/resources."""
- for versioned_info in self.infos[item_type].values():
- for item_info in versioned_info.by_version.values():
- yield item_info
-
- def _report_missing(self) -> None:
- """
- Use logger to print information about items that are referenced but
- were not loaded.
- """
- def report_missing_dependency(info: ResourceInfo, dep: str) -> None:
- msg = f_('no_dep_{resource}_{ver}_{dep}')\
- .format(dep=dep, resource=info.identifier,
- ver=util.version_string(info.version))
- logging.error(msg)
-
- for resource_info in self._all_of_type('resource'):
- for dep in resource_info.dependencies:
- if dep not in self.infos['resource']:
- report_missing_dependency(resource_info, dep)
-
- def report_missing_payload(info: MappingInfo, payload: str) -> None:
- msg = f_('no_payload_{mapping}_{ver}_{payload}')\
- .format(mapping=info.identifier, payload=payload,
- ver=util.version_string(info.version))
- logging.error(msg)
-
- for mapping_info in self._all_of_type('mapping'):
- for payload in mapping_info.payloads.values():
- if payload not in self.infos['resource']:
- report_missing_payload(mapping_info, payload)
-
- def _finalize(self):
- """
- Initialize structures needed to serve queries. Called once after all
- data gets loaded.
- """
- for infos_dict in self.infos.values():
- for versioned_info in infos_dict.values():
- versioned_info.known_versions.sort()
-
- for info in self._all_of_type('mapping'):
- for pattern in info.payloads:
- try:
- PatternMapping(pattern, info).register(self.pattern_tree)
- except Exception as e:
- if flask.current_app._hydrilla_werror:
- raise e from None
- msg = f_('couldnt_register_{mapping}_{ver}_{pattern}')\
- .format(mapping=info.identifier, pattern=pattern,
- ver=util.version_string(info.version))
- logging.error(msg)
-
- def query(self, url: str) -> list[MappingInfo]:
- """
- Return a list of registered mappings that match url.
-
- If multiple versions of a mapping are applicable, only the most recent
- is included in the result.
- """
- deco = DeconstructedUrl(url)
-
- collected = {}
-
- domain_tree = self.pattern_tree.get(deco.proto) or PatternTreeNode()
-
- def process_mapping(pattern_mapping: PatternMapping) -> None:
- if url[-1] != '/' and pattern_mapping.pattern[-1] == '/':
- return
-
- info = pattern_mapping.mapping_info
-
- if info.identifier not in collected or \
- info.version > collected[info.identifier].version:
- collected[info.identifier] = info
-
- for path_tree in domain_tree.search(deco.domain):
- for matches_list in path_tree.search(deco.path):
- for pattern_mapping in matches_list:
- process_mapping(pattern_mapping)
-
- return list(collected.values())
bp = flask.Blueprint('bp', __package__)
@@ -467,46 +73,36 @@ class HydrillaApp(flask.Flask):
]
}
- self._hydrilla_translation = \
- util.translation(here / 'locales', hydrilla_config['language'])
- self._hydrilla_project_url = hydrilla_config['hydrilla_project_url']
self._hydrilla_port = hydrilla_config['port']
self._hydrilla_werror = hydrilla_config.get('werror', False)
+ verify_files = hydrilla_config.get('verify_files', True)
if 'hydrilla_parent' in hydrilla_config:
- raise ValueError("Option 'hydrilla_parent' is not implemented.")
+ raise HaketiloException(_('err.server.opt_hydrilla_parent_not_implemented'))
- malcontent_dir = Path(hydrilla_config['malcontent_dir']).resolve()
- with self.app_context():
- self._hydrilla_malcontent = Malcontent(malcontent_dir)
+ malcontent_dir_path = Path(hydrilla_config['malcontent_dir']).resolve()
+ self._hydrilla_malcontent = malcontent.Malcontent(
+ malcontent_dir_path = malcontent_dir_path,
+ werror = self._hydrilla_werror,
+ verify_files = verify_files
+ )
- self.register_blueprint(bp)
+ self.jinja_env.install_gettext_translations(make_translation())
- def create_jinja_environment(self, *args, **kwargs) \
- -> flask.templating.Environment:
- """
- Flask's create_jinja_environment(), but tweaked to always include the
- 'hydrilla_project_url' global variable and to install proper
- translations.
- """
- env = super().create_jinja_environment(*args, **kwargs)
- env.install_gettext_translations(self._hydrilla_translation)
- env.globals['hydrilla_project_url'] = self._hydrilla_project_url
+ self.jinja_env.globals['hydrilla_project_url'] = \
+ hydrilla_config['hydrilla_project_url']
- return env
+ self.register_blueprint(bp)
def run(self, *args, **kwargs):
"""
- Flask's run(), but tweaked to use the port from hydrilla configuration
- by default.
+ Flask's run() but tweaked to use the port from hydrilla configuration by
+ default.
"""
return super().run(*args, port=self._hydrilla_port, **kwargs)
-def f_(text_key):
- return flask.current_app._hydrilla_translation.gettext(text_key)
-
-def malcontent():
- return flask.current_app._hydrilla_malcontent
+def get_malcontent() -> malcontent.Malcontent:
+ return t.cast(HydrillaApp, flask.current_app)._hydrilla_malcontent
@bp.route('/')
def index():
@@ -514,7 +110,8 @@ def index():
identifier_json_re = re.compile(r'^([-0-9a-z.]+)\.json$')
-def get_resource_or_mapping(item_type: str, identifier: str) -> Response:
+def get_resource_or_mapping(item_type: str, identifier: str) \
+ -> werkzeug.Response:
"""
Strip '.json' from 'identifier', look the item up and send its JSON
description.
@@ -525,36 +122,84 @@ def get_resource_or_mapping(item_type: str, identifier: str) -> Response:
identifier = match.group(1)
- versioned_info = malcontent().infos[item_type].get(identifier)
+ infos: t.Mapping[str, item_infos.VersionedItemInfo]
+ if item_type == 'resource':
+ infos = get_malcontent().resource_infos
+ else:
+ infos = get_malcontent().mapping_infos
- info = versioned_info and versioned_info.get_by_ver()
- if info is None:
+ versioned_info = infos.get(identifier)
+
+ if versioned_info is None:
flask.abort(404)
+ info = versioned_info.newest_info
+
# no need for send_from_directory(); path is safe, constructed by us
- file_path = malcontent().malcontent_dir_path / item_type / info.path()
- return flask.send_file(open(file_path, 'rb'), mimetype='application/json')
+ info_path = f'{info.identifier}/{versions.version_string(info.version)}'
+ file_path = get_malcontent().malcontent_dir_path / item_type / info_path
+
+ if flask.__version__[0:2] in ('0.', '1.'):
+ caching_args = {'add_etags': False, 'cache_timeout': 0}
+ else:
+ caching_args = {'etag': False}
+
+ return flask.send_file(
+ str(file_path),
+ mimetype = 'application/json',
+ conditional = False,
+ **caching_args # type: ignore
+ )
@bp.route('/mapping/<string:identifier_dot_json>')
-def get_newest_mapping(identifier_dot_json: str) -> Response:
+def get_newest_mapping(identifier_dot_json: str) -> werkzeug.Response:
return get_resource_or_mapping('mapping', identifier_dot_json)
@bp.route('/resource/<string:identifier_dot_json>')
-def get_newest_resource(identifier_dot_json: str) -> Response:
+def get_newest_resource(identifier_dot_json: str) -> werkzeug.Response:
return get_resource_or_mapping('resource', identifier_dot_json)
+def make_ref(info: item_infos.AnyInfo) -> t.Dict[str, t.Any]:
+ ref: t.Dict[str, t.Any] = {
+ 'version': info.version,
+ 'identifier': info.identifier,
+ 'long_name': info.long_name
+ }
+
+ if isinstance(info, item_infos.ResourceInfo):
+ ref['revision'] = info.revision
+
+ return ref
+
@bp.route('/query')
def query():
url = flask.request.args['url']
- mapping_refs = [i.as_query_result() for i in malcontent().query(url)]
+ mapping_refs = [make_ref(info) for info in get_malcontent().query(url)]
+
result = {
'$schema': 'https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json',
'mappings': mapping_refs,
'generated_by': generated_by
}
- return Response(json.dumps(result), mimetype='application/json')
+ return werkzeug.Response(json.dumps(result), mimetype='application/json')
+
+@bp.route('/list_all')
+def list_all_packages():
+ malcontent = get_malcontent()
+
+ resource_refs = [make_ref(info) for info in malcontent.get_all_resources()]
+ mapping_refs = [make_ref(info) for info in malcontent.get_all_mappings()]
+
+ result = {
+ '$schema': 'https://hydrilla.koszko.org/schemas/api_package_list-2.schema.json',
+ 'resources': resource_refs,
+ 'mappings': mapping_refs,
+ 'generated_by': generated_by
+ }
+
+ return werkzeug.Response(json.dumps(result), mimetype='application/json')
@bp.route('/--help')
def mm_help():
@@ -569,9 +214,6 @@ default_config_path = Path('/etc/hydrilla/config.json')
default_malcontent_dir = '/var/lib/hydrilla/malcontent'
default_project_url = 'https://hydrillabugs.koszko.org/projects/hydrilla/wiki'
-console_gettext = util.translation(here / 'locales').gettext
-_ = console_gettext
-
@click.command(help=_('serve_hydrilla_packages_explain_wsgi_considerations'))
@click.option('-m', '--malcontent-dir',
type=click.Path(exists=True, file_okay=False),
@@ -583,24 +225,25 @@ _ = console_gettext
@click.option('-c', '--config', 'config_path',
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
help=_('path_to_config_file_explain_default'))
-@click.option('-l', '--language', type=click.STRING,
- help=_('language_to_use_overrides_config'))
@click.version_option(version=_version.version, prog_name='Hydrilla',
message=_('%(prog)s_%(version)s_license'),
help=_('version_printing'))
-def start(malcontent_dir: Optional[str], hydrilla_project_url: Optional[str],
- port: Optional[int], config_path: Optional[str],
- language: Optional[str]) -> None:
+def start(
+ malcontent_dir: t.Optional[str],
+ hydrilla_project_url: t.Optional[str],
+ port: t.Optional[int],
+ config_path: t.Optional[str]
+) -> None:
"""
Run a development Hydrilla server.
This command is meant to be the entry point of hydrilla command exported by
this package.
"""
- config_load_opts = {} if config_path is None \
- else {'config_path': [Path(config_path)]}
-
- hydrilla_config = config.load(**config_load_opts)
+ if config_path is None:
+ hydrilla_config = config.load()
+ else:
+ hydrilla_config = config.load(config_paths=[Path(config_path)])
if malcontent_dir is not None:
hydrilla_config['malcontent_dir'] = str(Path(malcontent_dir).resolve())
@@ -611,14 +254,7 @@ def start(malcontent_dir: Optional[str], hydrilla_project_url: Optional[str],
if port is not None:
hydrilla_config['port'] = port
- if language is not None:
- hydrilla_config['language'] = language
-
- lang = hydrilla_config.get('language')
- _ = console_gettext if lang is None else \
- util.translation(here / 'locales', lang).gettext
-
- for opt in ('malcontent_dir', 'hydrilla_project_url', 'port', 'language'):
+ for opt in ('malcontent_dir', 'hydrilla_project_url', 'port'):
if opt not in hydrilla_config:
raise ValueError(_('config_option_{}_not_supplied').format(opt))
@@ -632,7 +268,7 @@ def start(malcontent_dir: Optional[str], hydrilla_project_url: Optional[str],
@click.version_option(version=_version.version, prog_name='Hydrilla',
message=_('%(prog)s_%(version)s_license'),
help=_('version_printing'))
-def start_wsgi() -> None:
+def start_wsgi() -> flask.Flask:
"""
Create application object for use in WSGI deployment.
diff --git a/src/hydrilla/server/templates/base.html b/src/hydrilla/server/templates/base.html
index 34cb214..7d8c3a6 100644
--- a/src/hydrilla/server/templates/base.html
+++ b/src/hydrilla/server/templates/base.html
@@ -19,8 +19,9 @@ License for more details.
I, Wojtek Kosior, thereby promise not to sue for violation of this
-file's license. Although I request that you do not make use this code
-in a proprietary program, I am not going to enforce this in court.
+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.
#}
{% macro link_for(endpoint, text) -%}
diff --git a/src/hydrilla/server/templates/index.html b/src/hydrilla/server/templates/index.html
index 3063239..b3a1325 100644
--- a/src/hydrilla/server/templates/index.html
+++ b/src/hydrilla/server/templates/index.html
@@ -19,8 +19,9 @@ License for more details.
I, Wojtek Kosior, thereby promise not to sue for violation of this
-file's license. Although I request that you do not make use this code
-in a proprietary program, I am not going to enforce this in court.
+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.
#}
{% extends 'base.html' %}
diff --git a/src/hydrilla/translations.py b/src/hydrilla/translations.py
new file mode 100644
index 0000000..f6e6760
--- /dev/null
+++ b/src/hydrilla/translations.py
@@ -0,0 +1,107 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Handling of gettext for Hydrilla.
+#
+# This file is part of Hydrilla
+#
+# Copyright (C) 2021, 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.
+
+import locale as lcl
+import gettext
+import typing as t
+
+from pathlib import Path
+
+here = Path(__file__).resolve().parent
+
+localedir = here / 'locales'
+
+supported_locales = [f.name for f in localedir.iterdir() if f.is_dir()]
+
+default_locale = 'en_US'
+
+def select_best_locale(supported: t.Sequence[str] = supported_locales) -> str:
+ """
+ ....
+
+ Otherwise, try to determine system's default language and use that.
+ """
+ # TODO: Stop referenceing flask here. Instead, allow other code to register
+ # custom locale resolvers and register flask-aware resolver during
+ # runtime from within the flask-related part(s) of the application.
+ try:
+ import flask
+ use_flask = flask.has_request_context()
+ except ModuleNotFoundError:
+ use_flask = False
+
+ if use_flask:
+ best = flask.request.accept_languages.best_match(
+ supported,
+ default = default_locale
+ )
+ assert best is not None
+ return best
+
+ # https://stackoverflow.com/questions/3425294/how-to-detect-the-os-default-language-in-python
+ # I am not going to surrender to Microbugs' nonfree, crappy OS to test it,
+ # so the lines inside try: block may actually fail.
+ locale: t.Optional[str] = lcl.getdefaultlocale()[0]
+ try:
+ from ctypes.windll import kernel32 as windll # type: ignore
+ locale = lcl.windows_locale[windll.GetUserDefaultUILanguage()]
+ except:
+ pass
+
+ if locale is None or locale not in supported:
+ locale = default_locale
+
+ return locale
+
+translations: t.Dict[str, gettext.NullTranslations] = {}
+
+def translation(locale: t.Optional[str] = None) -> gettext.NullTranslations:
+ """
+ Configure translations for domain 'messages' and return the object that
+ represents them. If the requested locale is not available, fall back to
+ 'en_US'.
+ """
+ if locale is None:
+ locale = select_best_locale()
+
+ if not (localedir / locale).is_dir():
+ locale = 'en_US'
+
+ if locale not in translations:
+ translations[locale] = gettext.translation(
+ 'messages',
+ localedir=localedir,
+ languages=[locale]
+ )
+
+ return translations[locale]
+
+def smart_gettext(msg: str, locale: t.Optional[str] = None) -> str:
+ """...."""
+ return translation(locale).gettext(msg)
+
+_ = smart_gettext
diff --git a/src/hydrilla/url_patterns.py b/src/hydrilla/url_patterns.py
new file mode 100644
index 0000000..84f56bc
--- /dev/null
+++ b/src/hydrilla/url_patterns.py
@@ -0,0 +1,237 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Data structure for querying URL patterns.
+#
+# This file is part of Hydrilla&Haketilo.
+#
+# Copyright (C) 2021, 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 contains functions for deconstruction and construction of URLs and
+Haketilo URL patterns.
+
+Data structures for querying data using URL patterns are also defined there.
+"""
+
+import re
+import urllib.parse as up
+import typing as t
+import dataclasses as dc
+
+from immutables import Map
+
+from .translations import smart_gettext as _
+from .exceptions import HaketiloException
+
+
+class HaketiloURLException(HaketiloException):
+ """Type used for exceptions generated when parsing a URL or URL pattern."""
+ pass
+
+
+default_ports: t.Mapping[str, int] = Map(http=80, https=443, ftp=21)
+
+ParsedUrlType = t.TypeVar('ParsedUrlType', bound='ParsedUrl')
+
+@dc.dataclass(frozen=True, unsafe_hash=True, order=True)
+class ParsedUrl:
+ """...."""
+ orig_url: str # used in __hash__() and __lt__()
+ scheme: str = dc.field(hash=False, compare=False)
+ domain_labels: t.Tuple[str, ...] = dc.field(hash=False, compare=False)
+ path_segments: t.Tuple[str, ...] = dc.field(hash=False, compare=False)
+ query: str = dc.field(hash=False, compare=False)
+ has_trailing_slash: bool = dc.field(hash=False, compare=False)
+ port: t.Optional[int] = dc.field(hash=False, compare=False)
+
+ @property
+ def url_without_path(self) -> str:
+ """...."""
+ scheme = self.scheme
+
+ netloc = '.'.join(reversed(self.domain_labels))
+
+ if self.port is not None and \
+ default_ports.get(scheme) != self.port:
+ netloc += f':{self.port}'
+
+ return f'{scheme}://{netloc}'
+
+ def reconstruct_url(self) -> str:
+ """...."""
+ path = '/'.join(('', *self.path_segments))
+ if self.has_trailing_slash:
+ path += '/'
+
+ return self.url_without_path + path
+
+ def path_append(self: ParsedUrlType, *new_segments: str) -> ParsedUrlType:
+ """...."""
+ new_url = self.reconstruct_url()
+ if not self.has_trailing_slash:
+ new_url += '/'
+
+ new_url += '/'.join(new_segments)
+
+ return dc.replace(
+ self,
+ orig_url = new_url,
+ path_segments = tuple((*self.path_segments, *new_segments)),
+ has_trailing_slash = False
+ )
+
+ParsedPattern = t.NewType('ParsedPattern', ParsedUrl)
+
+
+# URLs with those schemes will be recognized but not all of them have to be
+# actually supported by Hydrilla server and Haketilo proxy.
+supported_schemes = 'http', 'https', 'ftp', 'file'
+
+def _parse_pattern_or_url(
+ url: str,
+ orig_url: str,
+ is_pattern: bool = False
+) -> ParsedUrl:
+ """...."""
+ if not is_pattern:
+ assert orig_url == url
+
+ parse_result = up.urlparse(url)
+
+ # Verify the parsed URL is valid
+ has_hostname = parse_result.hostname is not None
+ if not parse_result.scheme or \
+ (parse_result.scheme == 'file' and parse_result.port is not None) or \
+ (parse_result.scheme == 'file' and has_hostname) or \
+ (parse_result.scheme != 'file' and not has_hostname):
+ if is_pattern:
+ msg = _('err.url_pattern_{}.bad').format(orig_url)
+ raise HaketiloURLException(msg)
+ else:
+ raise HaketiloURLException(_('err.url_{}.bad') .format(url))
+
+ # Verify the URL uses a known scheme and extract it.
+ scheme = parse_result.scheme
+
+ if parse_result.scheme not in supported_schemes:
+ if is_pattern:
+ msg = _('err.url_pattern_{}.bad_scheme').format(orig_url)
+ raise HaketiloURLException(msg)
+ else:
+ raise HaketiloURLException(_('err.url_{}.bad_scheme').format(url))
+
+ # Extract and keep information about special pattern schemas used.
+ if is_pattern and orig_url.startswith('http*:'):
+ if parse_result.port:
+ fmt = _('err.url_pattern_{}.special_scheme_port')
+ raise HaketiloURLException(fmt.format(orig_url))
+
+ # Extract URL's explicit port or deduce the port based on URL's protocol.
+ try:
+ explicit_port = parse_result.port
+ port_out_of_range = explicit_port == 0
+ except ValueError:
+ port_out_of_range = True
+
+ if port_out_of_range:
+ if is_pattern:
+ msg = _('err.url_pattern_{}.bad_port').format(orig_url)
+ raise HaketiloURLException(msg)
+ else:
+ raise HaketiloURLException(_('err.url_{}.bad_port').format(url))
+
+ port = explicit_port or default_ports.get(parse_result.scheme)
+
+ # Make URL's hostname into a list of labels in reverse order. E.g.
+ # 'https://a.bc..de.fg.com/h/i/' -> ['com', 'fg', 'de', 'bc', 'a']
+ hostname = parse_result.hostname or ''
+ domain_labels_with_empty = reversed(hostname.split('.'))
+ domain_labels = tuple(lbl for lbl in domain_labels_with_empty if lbl)
+
+ # Make URL's path into a list of segments. E.g.
+ # 'https://ab.cd/e//f/g/' -> ['e', 'f', 'g']
+ path_segments_with_empty = parse_result.path.split('/')
+ path_segments = tuple(sgmt for sgmt in path_segments_with_empty if sgmt)
+
+ # Record whether a trailing '/' is present in the URL.
+ has_trailing_slash = parse_result.path.endswith('/')
+
+ # Perform some additional sanity checks and return the result.
+ if is_pattern:
+ if parse_result.query:
+ msg = _('err.url_pattern_{}.has_query').format(orig_url)
+ raise HaketiloURLException(msg)
+
+ if parse_result.fragment:
+ msg = _('err.url_pattern_{}.has_frag').format(orig_url)
+ raise HaketiloURLException(msg)
+
+ query = parse_result.query
+
+ return ParsedUrl(
+ orig_url = orig_url,
+ scheme = scheme,
+ port = port,
+ domain_labels = domain_labels,
+ path_segments = path_segments,
+ query = query,
+ has_trailing_slash = has_trailing_slash
+ )
+
+replace_scheme_regex = re.compile(r'^[^:]*')
+
+def parse_pattern(url_pattern: str) -> t.Iterator[ParsedPattern]:
+ """...."""
+ if url_pattern.startswith('http*:'):
+ patterns = [
+ replace_scheme_regex.sub('http', url_pattern),
+ replace_scheme_regex.sub('https', url_pattern)
+ ]
+ else:
+ patterns = [url_pattern]
+
+ for pat in patterns:
+ yield ParsedPattern(
+ _parse_pattern_or_url(pat, url_pattern, True)
+ )
+
+def parse_url(url: str) -> ParsedUrl:
+ """...."""
+ return _parse_pattern_or_url(url, url)
+
+
+def normalize_pattern(url_pattern: str) -> str:
+ parsed = next(parse_pattern(url_pattern))
+
+ reconstructed = parsed.reconstruct_url()
+
+ if url_pattern.startswith('http*'):
+ reconstructed = replace_scheme_regex.sub('http*', reconstructed)
+
+ return reconstructed
+
+
+def pattern_for_domain(url: str) -> str:
+ return normalize_pattern(f'http*://{up.urlparse(url).netloc}/***')
+
+
+dummy_url = parse_url('http://dummy.replacement.url')
diff --git a/src/hydrilla/versions.py b/src/hydrilla/versions.py
new file mode 100644
index 0000000..2071864
--- /dev/null
+++ b/src/hydrilla/versions.py
@@ -0,0 +1,78 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Functions to operate on version numbers.
+#
+# This file is part of Hydrilla&Haketilo.
+#
+# Copyright (C) 2021, 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 contains functions for deconstruction and construction of version
+strings and version tuples.
+"""
+
+import typing as t
+
+from itertools import takewhile
+
+from . import _version
+
+
+VerTuple = t.NewType('VerTuple', 't.Tuple[int, ...]')
+
+def normalize(ver: t.Sequence[int]) -> VerTuple:
+ """Strip rightmost zeroes from 'ver'."""
+ new_len = 0
+ for i, num in enumerate(ver):
+ if num != 0:
+ new_len = i + 1
+
+ return VerTuple(tuple(ver[:new_len]))
+
+def parse(ver_str: str) -> t.Tuple[int, ...]:
+ """
+ Convert 'ver_str' into an array representation, e.g. for ver_str="4.6.13.0"
+ return [4, 6, 13, 0].
+ """
+ return tuple(int(num) for num in ver_str.split('.'))
+
+def parse_normalize(ver_str: str) -> VerTuple:
+ """
+ Convert 'ver_str' into a VerTuple representation, e.g. for
+ ver_str="4.6.13.0" return (4, 6, 13).
+ """
+ return normalize(parse(ver_str))
+
+def version_string(ver: VerTuple, rev: t.Optional[int] = None) -> str:
+ """
+ Produce version's string representation (optionally with revision), like:
+ 1.2.3-5
+ """
+ return '.'.join(str(n) for n in ver) + ('' if rev is None else f'-{rev}')
+
+haketilo_version = normalize(tuple(takewhile(
+ lambda i: isinstance(i, int),
+ _version.version_tuple # type: ignore
+)))
+
+int_ver_min = normalize([1])
+int_ver_max = normalize([65536])