diff options
author | Wojtek Kosior <koszko@koszko.org> | 2022-08-18 19:18:00 +0200 |
---|---|---|
committer | Wojtek Kosior <koszko@koszko.org> | 2022-09-28 12:54:22 +0200 |
commit | e1344ae7017b28a54d7714895bd54c8431a20bc6 (patch) | |
tree | 66bfcb166a87afa10a0b45100231c102385baf08 | |
parent | 2579081df2a568192887d776a6965af323b7c4ee (diff) | |
download | haketilo-hydrilla-e1344ae7017b28a54d7714895bd54c8431a20bc6.tar.gz haketilo-hydrilla-e1344ae7017b28a54d7714895bd54c8431a20bc6.zip |
allow adding, removing and altering repositories
This commit also temporarily breaks package import from files :/
-rw-r--r-- | src/hydrilla/proxy/state.py | 37 | ||||
-rw-r--r-- | src/hydrilla/proxy/state_impl/concrete_state.py | 4 | ||||
-rw-r--r-- | src/hydrilla/proxy/state_impl/prune_packages.py | 152 | ||||
-rw-r--r-- | src/hydrilla/proxy/state_impl/repos.py | 147 | ||||
-rw-r--r-- | src/hydrilla/proxy/tables.sql | 42 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/_app.py | 5 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/packages.py | 23 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/repos.py | 84 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/root.py | 18 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/base.html.jinja | 6 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/include/checkbox_tricks_style.css.jinja | 4 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/packages.html.jinja | 11 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/packages__load_from_disk.html.jinja | 5 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/repos.html.jinja | 7 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/repos__add.html.jinja | 62 | ||||
-rw-r--r-- | src/hydrilla/proxy/web_ui/templates/repos__show_single.html.jinja | 40 |
16 files changed, 553 insertions, 94 deletions
diff --git a/src/hydrilla/proxy/state.py b/src/hydrilla/proxy/state.py index c3712f2..6414ae8 100644 --- a/src/hydrilla/proxy/state.py +++ b/src/hydrilla/proxy/state.py @@ -43,6 +43,7 @@ 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 @@ -81,6 +82,15 @@ class Store(ABC, t.Generic[RefType]): ... +class RepoNameInvalid(HaketiloException): + pass + +class RepoNameTaken(HaketiloException): + pass + +class RepoUrlInvalid(HaketiloException): + pass + # 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] @@ -97,7 +107,7 @@ class RepoRef(Ref): *, name: t.Optional[str] = None, url: t.Optional[str] = None - ) -> 'RepoRef': + ) -> None: """....""" ... @@ -112,13 +122,14 @@ class RepoRef(Ref): @dc.dataclass(frozen=True) class RepoDisplayInfo: - ref: RepoRef - name: str - url: t.Optional[str] - deleted: t.Optional[bool] - last_refreshed: t.Optional[datetime] - resource_count: int - mapping_count: int + 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 @@ -126,6 +137,10 @@ class RepoStore(Store[RepoRef]): t.Iterable[RepoDisplayInfo]: ... + @abstractmethod + def add(self, name: str, url: str) -> RepoRef: + ... + @dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc] class RepoIterationRef(Ref): @@ -312,12 +327,6 @@ class HaketiloState(ABC): ... @abstractmethod - def add_repo(self, name: t.Optional[str], url: t.Optional[str]) \ - -> RepoRef: - """....""" - ... - - @abstractmethod def get_settings(self) -> HaketiloGlobalSettings: """....""" ... diff --git a/src/hydrilla/proxy/state_impl/concrete_state.py b/src/hydrilla/proxy/state_impl/concrete_state.py index 46e7827..525a702 100644 --- a/src/hydrilla/proxy/state_impl/concrete_state.py +++ b/src/hydrilla/proxy/state_impl/concrete_state.py @@ -446,10 +446,6 @@ class ConcreteHaketiloState(base.HaketiloStateWithFields): def get_payload(self, payload_id: str) -> st.PayloadRef: raise NotImplementedError() - def add_repo(self, name: t.Optional[str], url: t.Optional[str]) \ - -> st.RepoRef: - raise NotImplementedError() - def get_settings(self) -> st.HaketiloGlobalSettings: return st.HaketiloGlobalSettings( mapping_use_mode = st.MappingUseMode.AUTO, diff --git a/src/hydrilla/proxy/state_impl/prune_packages.py b/src/hydrilla/proxy/state_impl/prune_packages.py new file mode 100644 index 0000000..1857188 --- /dev/null +++ b/src/hydrilla/proxy/state_impl/prune_packages.py @@ -0,0 +1,152 @@ +# 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 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 + +import sqlite3 + + +_remove_mapping_versions_sql = ''' +WITH removed_mappings AS ( + SELECT + iv.item_version_id + FROM + item_versions AS iv + JOIN items AS i + USING (item_id) + JOIN orphan_iterations AS oi + USING (repo_iteration_id) + LEFT JOIN payloads AS p + ON p.mapping_item_id = iv.item_version_id + WHERE + i.type = 'M' AND p.payload_id IS NULL +) +DELETE FROM + item_versions +WHERE + item_version_id IN removed_mappings; +''' + +_remove_resource_versions_sql = ''' +WITH removed_resources AS ( + SELECT + iv.item_version_id + FROM + item_versions AS iv + JOIN items AS i + USING (item_id) + JOIN orphan_iterations AS oi + USING (repo_iteration_id) + LEFT JOIN resolved_depended_resources AS rdr + ON rdr.resource_item_id = iv.item_version_id + WHERE + rdr.payload_id IS NULL +) +DELETE FROM + item_versions +WHERE + item_version_id IN removed_resources; +''' + +_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; +''' + +_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 +) +DELETE FROM + repos +WHERE + repo_id IN removed_repos; +''' + +def prune(cursor: sqlite3.Cursor) -> None: + """....""" + cursor.execute(_remove_mapping_versions_sql) + cursor.execute(_remove_resource_versions_sql) + cursor.execute(_remove_items_sql) + cursor.execute(_remove_files_sql) + cursor.execute(_remove_repo_iterations_sql) + cursor.execute(_remove_repos_sql) diff --git a/src/hydrilla/proxy/state_impl/repos.py b/src/hydrilla/proxy/state_impl/repos.py index be11a88..5553ec2 100644 --- a/src/hydrilla/proxy/state_impl/repos.py +++ b/src/hydrilla/proxy/state_impl/repos.py @@ -32,19 +32,52 @@ inside Haketilo. # Enable using with Python 3.7. from __future__ import annotations +import re import typing as t import dataclasses as dc +from urllib.parse import urlparse from datetime import datetime +import sqlite3 + from .. import state as st from . import base +from . import prune_packages + + +def validate_repo_url(url: str) -> None: + try: + parsed = urlparse(url) + except: + raise st.RepoUrlInvalid() + + if parsed.scheme not in ('http', 'https'): + raise st.RepoUrlInvalid() + + +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 make_repo_display_info( ref: st.RepoRef, name: str, - url: t.Optional[str], - deleted: t.Optional[bool], + url: str, + deleted: bool, last_refreshed: t.Optional[int], resource_count: int, mapping_count: int @@ -54,13 +87,14 @@ def make_repo_display_info( last_refreshed_converted = datetime.fromtimestamp(last_refreshed) return st.RepoDisplayInfo( - ref = ref, - name = name, - url = url, - deleted = deleted, - last_refreshed = last_refreshed_converted, - resource_count = resource_count, - mapping_count = mapping_count + 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 ) @@ -70,15 +104,47 @@ class ConcreteRepoRef(st.RepoRef): state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False) def remove(self) -> None: - raise NotImplementedError() + 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,) + ) def update( self, *, name: t.Optional[str] = None, url: t.Optional[str] = None - ) -> st.RepoRef: - raise NotImplementedError() + ) -> None: + if name is not None: + raise NotImplementedError() + + if url is None: + return + + validate_repo_url(url) + + with self.state.cursor(transaction=True) as cursor: + ensure_repo_not_deleted(cursor, self.id) + + cursor.execute( + 'UPDATE repos SET url = ? WHERE repo_id = ?;', + (url, self.id) + ) + + prune_packages.prune(cursor) def refresh(self) -> st.RepoIterationRef: raise NotImplementedError() @@ -108,6 +174,19 @@ class ConcreteRepoRef(st.RepoRef): return make_repo_display_info(self, *row) +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) + @dc.dataclass(frozen=True) class ConcreteRepoStore(st.RepoStore): state: base.HaketiloStateWithFields @@ -115,6 +194,50 @@ class ConcreteRepoStore(st.RepoStore): def get(self, id: str) -> st.RepoRef: return ConcreteRepoRef(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() + + validate_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, deleted, next_iteration) + VALUES (?, ?, FALSE, 1) + 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.Iterable[st.RepoDisplayInfo]: with self.state.cursor() as cursor: diff --git a/src/hydrilla/proxy/tables.sql b/src/hydrilla/proxy/tables.sql index a915f74..fc7c65c 100644 --- a/src/hydrilla/proxy/tables.sql +++ b/src/hydrilla/proxy/tables.sql @@ -67,25 +67,34 @@ CREATE TABLE repos( repo_id INTEGER PRIMARY KEY, name VARCHAR NOT NULL, - url VARCHAR NULL, - deleted BOOLEAN NULL, + url VARCHAR NOT NULL, + deleted BOOLEAN NOT NULL, next_iteration INTEGER NOT NULL, active_iteration_id INTEGER NULL, last_refreshed INTEGER NULL, UNIQUE (name), - CHECK ((repo_id = 1) = (name = '<local>')), - CHECK ((repo_id = 1) = (url IS NULL)), - CHECK ((repo_id = 1) = (deleted IS NULL)), - CHECK (repo_id != 1 OR last_refreshed IS NULL), + -- 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, next_iteration) -VALUES(1, '<local>', 1); +INSERT INTO repos(repo_id, name, url, deleted, next_iteration) +VALUES(1, '', '', TRUE, 1); CREATE TABLE repo_iterations( repo_iteration_id INTEGER PRIMARY KEY, @@ -133,10 +142,19 @@ CREATE TABLE mapping_statuses( -- EXACT_VERSION, is to be updated only with versions from the same -- REPOSITORY or is NOT_FROZEN at all. frozen CHAR(1) NULL, + 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')) + CHECK (frozen IS NULL OR frozen in ('E', 'R', 'N')), + CHECK (enabled != 'E' OR active_version_id IS NOT NULL) + CHECK (enabled != 'D' OR active_version_id IS NULL) + + FOREIGN KEY (item_id) + REFERENCES items (item_id) + ON DELETE CASCADE, + FOREIGN KEY (active_version_id, item_id) + REFERENCES item_versions (item_version_id, item_id) ); CREATE TABLE item_versions( @@ -148,6 +166,8 @@ CREATE TABLE item_versions( definition TEXT NOT NULL, UNIQUE (item_id, version, repo_iteration_id), + -- Constraint below needed to allow foreign key from "mapping_statuses". + UNIQUE (item_version_id, item_id), FOREIGN KEY (item_id) REFERENCES items (item_id), @@ -169,6 +189,8 @@ FROM 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, +-- all dependencies the "payloads" table and those that reference it are CREATE TABLE payloads( payload_id INTEGER PRIMARY KEY, @@ -202,7 +224,7 @@ SELECT COALESCE( r.active_iteration_id != ri.repo_iteration_id, TRUE - ) AND r.name != '<local>' AS is_orphan + ) AND r.repo_id != 1 AS is_orphan FROM item_versions AS iv LEFT JOIN payloads AS p diff --git a/src/hydrilla/proxy/web_ui/_app.py b/src/hydrilla/proxy/web_ui/_app.py index d5783d1..ab15918 100644 --- a/src/hydrilla/proxy/web_ui/_app.py +++ b/src/hydrilla/proxy/web_ui/_app.py @@ -4,6 +4,8 @@ # # Available under the terms of Creative Commons Zero v1.0 Universal. +import typing as t + import flask from .. import state as st @@ -11,3 +13,6 @@ from .. import state as st class WebUIApp(flask.Flask): _haketilo_state: st.HaketiloState + +def get_haketilo_state() -> st.HaketiloState: + return t.cast(WebUIApp, flask.current_app)._haketilo_state diff --git a/src/hydrilla/proxy/web_ui/packages.py b/src/hydrilla/proxy/web_ui/packages.py index f38ea13..90876aa 100644 --- a/src/hydrilla/proxy/web_ui/packages.py +++ b/src/hydrilla/proxy/web_ui/packages.py @@ -35,7 +35,6 @@ import tempfile import zipfile import typing as t -from urllib.parse import urlparse from pathlib import Path import flask @@ -55,16 +54,12 @@ class InvalidUploadedMalcontent(HaketiloException): bp = flask.Blueprint('load_packages', __package__) @bp.route('/packages/load_from_disk', methods=['GET']) -def load_from_disk_get() -> flask.Response: +def load_from_disk_get() -> werkzeug.Response: html = flask.render_template('packages__load_from_disk.html.jinja') return flask.make_response(html, 200) @bp.route('/packages/load_from_disk', methods=['POST']) def load_from_disk_post() -> werkzeug.Response: - parsed_url = urlparse(flask.request.referrer) - if parsed_url.netloc != 'hkt.mitm.it': - return load_from_disk_get() - zip_file_storage = flask.request.files.get('packages_zipfile') if zip_file_storage is None: return load_from_disk_get() @@ -90,31 +85,27 @@ def load_from_disk_post() -> werkzeug.Response: else: malcontent_dir_path = tmpdir_child - state = t.cast(_app.WebUIApp, flask.current_app)._haketilo_state - try: - state.import_packages(malcontent_dir_path) + _app.get_haketilo_state().import_packages(malcontent_dir_path) except: raise InvalidUploadedMalcontent() return flask.redirect(flask.url_for('.packages')) @bp.route('/packages') -def packages() -> flask.Response: - state = t.cast(_app.WebUIApp, flask.current_app)._haketilo_state +def packages() -> werkzeug.Response: + store = _app.get_haketilo_state().mapping_version_store() html = flask.render_template( 'packages.html.jinja', - display_infos = state.mapping_version_store().get_display_infos() + display_infos = store.get_display_infos() ) return flask.make_response(html, 200) @bp.route('/packages/view/<string:mapping_id>') -def show_package(mapping_id: str) -> flask.Response: - state = t.cast(_app.WebUIApp, flask.current_app)._haketilo_state - +def show_package(mapping_id: str) -> werkzeug.Response: try: - store = state.mapping_version_store() + store = _app.get_haketilo_state().mapping_version_store() display_info = store.get(mapping_id).get_display_info() html = flask.render_template( diff --git a/src/hydrilla/proxy/web_ui/repos.py b/src/hydrilla/proxy/web_ui/repos.py index d4e81c0..166cf53 100644 --- a/src/hydrilla/proxy/web_ui/repos.py +++ b/src/hydrilla/proxy/web_ui/repos.py @@ -34,6 +34,7 @@ from __future__ import annotations import typing as t import flask +import werkzeug from .. import state as st from . import _app @@ -41,11 +42,36 @@ from . import _app bp = flask.Blueprint('repos', __package__) +@bp.route('/repos/add', methods=['GET']) +def add_repo_get(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_get() + + 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_get({'repo_name_invalid': True}) + except st.RepoNameTaken: + return add_repo_get({'repo_name_taken': True}) + except st.RepoUrlInvalid: + return add_repo_get({'repo_url_invalid': True}) + + return flask.redirect(flask.url_for('.show_repo', repo_id=new_repo_ref.id)) + @bp.route('/repos') -def repos() -> flask.Response: - state = t.cast(_app.WebUIApp, flask.current_app)._haketilo_state +def repos() -> werkzeug.Response: + repo_store = _app.get_haketilo_state().repo_store() - local_semirepo_info, *repo_infos = state.repo_store().get_display_infos() + local_semirepo_info, *repo_infos = repo_store.get_display_infos() html = flask.render_template( 'repos.html.jinja', @@ -54,12 +80,10 @@ def repos() -> flask.Response: ) return flask.make_response(html, 200) -@bp.route('/repos/view/<string:repo_id>', methods=['GET']) -def show_repo(repo_id: str) -> flask.Response: - state = t.cast(_app.WebUIApp, flask.current_app)._haketilo_state - +@bp.route('/repos/view/<string:repo_id>') +def show_repo(repo_id: str) -> werkzeug.Response: try: - store = state.repo_store() + store = _app.get_haketilo_state().repo_store() display_info = store.get(repo_id).get_display_info() html = flask.render_template( @@ -70,6 +94,44 @@ def show_repo(repo_id: str) -> flask.Response: except st.MissingItemError: flask.abort(404) -@bp.route('/repos/view/<string:repo_id>', methods=['POST']) -def update_repo(repo_id: str) -> flask.Response: - raise NotImplementedError() +def sanitize_altered_repo_id(repo_id: str) -> str: + repo_id = str(int(repo_id)) + if repo_id == '1': + # Protect local semi-repo. + flask.abort(403) + + return repo_id + +@bp.route('/repos/update_url/<string:repo_id>', methods=['POST']) +def update_repo_url(repo_id: str) -> werkzeug.Response: + repo_id = sanitize_altered_repo_id(repo_id) + + try: + repo_ref = _app.get_haketilo_state().repo_store().get(repo_id) + repo_ref.update() + except st.MissingItemError: + flask.abort(404) + + return flask.redirect(flask.url_for('.show_repo', repo_id=repo_id)) + +@bp.route('/repos/remove/<string:repo_id>', methods=['POST']) +def remove_repo(repo_id: str): + repo_id = sanitize_altered_repo_id(repo_id) + + try: + _app.get_haketilo_state().repo_store().get(repo_id).remove() + except st.MissingItemError: + flask.abort(404) + + return flask.redirect(flask.url_for('.repos')) + +@bp.route('/repos/refresh/<string:repo_id>', methods=['POST']) +def refresh_repo(repo_id: str): + repo_id = sanitize_altered_repo_id(repo_id) + + try: + _app.get_haketilo_state().repo_store().get(repo_id).refresh() + 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 index 64d6be1..0f42981 100644 --- a/src/hydrilla/proxy/web_ui/root.py +++ b/src/hydrilla/proxy/web_ui/root.py @@ -32,10 +32,13 @@ from __future__ import annotations import typing as t + from threading import Lock +from urllib.parse import urlparse import jinja2 import flask +import werkzeug from ...translations import translation as make_translation from ... import versions @@ -46,6 +49,17 @@ from . import packages from . import _app +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) + + class WebUIAppImpl(_app.WebUIApp): def __init__(self): super().__init__(__name__) @@ -60,6 +74,8 @@ class WebUIAppImpl(_app.WebUIApp): ] } + self.before_request(authenticate_by_referrer) + for blueprint in [repos.bp, packages.bp]: self.register_blueprint(blueprint) @@ -71,7 +87,7 @@ app_lock = Lock() @app.route('/') -def respond(): +def respond() -> str: return flask.render_template('root.html.jinja') diff --git a/src/hydrilla/proxy/web_ui/templates/base.html.jinja b/src/hydrilla/proxy/web_ui/templates/base.html.jinja index bca5948..c7a0c15 100644 --- a/src/hydrilla/proxy/web_ui/templates/base.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/base.html.jinja @@ -45,6 +45,12 @@ in a proprietary work, I am not going to enforce this in court. color: #555; } + .error-note { + display: block; + border-left: 5px solid #a33; + background-color: #fcc; + } + .hide { display: none !important; } diff --git a/src/hydrilla/proxy/web_ui/templates/include/checkbox_tricks_style.css.jinja b/src/hydrilla/proxy/web_ui/templates/include/checkbox_tricks_style.css.jinja index b4c8edc..471725b 100644 --- a/src/hydrilla/proxy/web_ui/templates/include/checkbox_tricks_style.css.jinja +++ b/src/hydrilla/proxy/web_ui/templates/include/checkbox_tricks_style.css.jinja @@ -30,7 +30,7 @@ input.chbx-tricks-hide-show:not(:checked)+*+* { display: none !important; } -input.chbx-tricks-hide-show:checked+*+*, -input.chbx-tricks-show-hide:not(:checked)+*+* { +input.chbx-tricks-hide-show:checked+*, +input.chbx-tricks-show-hide:not(:checked)+* { display: none !important; } diff --git a/src/hydrilla/proxy/web_ui/templates/packages.html.jinja b/src/hydrilla/proxy/web_ui/templates/packages.html.jinja index 48ef80b..bcb8dea 100644 --- a/src/hydrilla/proxy/web_ui/templates/packages.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/packages.html.jinja @@ -30,11 +30,12 @@ in a proprietary work, I am not going to enforce this in court. <h3>{{ _('web_ui.packages.heading') }}</h3> <ul id="item_list"> {% for info in display_infos %} - <li - {% if info.info.repo == '<local>' %} - class="package-entry-local" - {% endif %} - > + {% if info.info.repo == '<local>' -%} + {%- set entry_classes = 'package-entry-local' -%} + {%- else -%} + {%- set entry_classes = '' -%} + {%- endif -%} + <li class="{{ entry_classes }}"> <a href="{{ url_for('.show_package', mapping_id=info.ref.id) }}"> <div> {{ info.info.long_name }} diff --git a/src/hydrilla/proxy/web_ui/templates/packages__load_from_disk.html.jinja b/src/hydrilla/proxy/web_ui/templates/packages__load_from_disk.html.jinja index 52280b2..33a99f4 100644 --- a/src/hydrilla/proxy/web_ui/templates/packages__load_from_disk.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/packages__load_from_disk.html.jinja @@ -20,7 +20,7 @@ file's licenses. Although I request that you do not make use this code in a proprietary work, I am not going to enforce this in court. #} {% extends "base.html.jinja" %} -{% block title %}Load package{% endblock %} +{% block title %} {{ _('web_ui.packages.load_from_disk.title') }} {% endblock %} {% block main %} <form method="POST" enctype="multipart/form-data"> <div> @@ -29,7 +29,8 @@ in a proprietary work, I am not going to enforce this in court. </label> </div> <div> - <input id="packages_zipfile" name="packages_zipfile" type="file" required=""> + <input id="packages_zipfile" name="packages_zipfile" type="file" + required=""> </div> <div> <button>Install packages</button> diff --git a/src/hydrilla/proxy/web_ui/templates/repos.html.jinja b/src/hydrilla/proxy/web_ui/templates/repos.html.jinja index 94b7e2b..05c948e 100644 --- a/src/hydrilla/proxy/web_ui/templates/repos.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/repos.html.jinja @@ -28,6 +28,11 @@ in a proprietary work, I am not going to enforce this in court. {% endblock %} {% block main %} <h3>{{ _('web_ui.repos.heading') }}</h3> + <div> + <a href="{{ url_for('.add_repo_get') }}" class="button"> + {{ _('web_ui.repos.add_repo_button') }} + </a> + </div> <ul id="item_list"> {% for info in display_infos %} <li @@ -51,7 +56,7 @@ in a proprietary work, I am not going to enforce this in court. </li> {% endfor %} <li> - <a href="{{ url_for('.show_repo', repo_id=1) }}"> + <a href="{{ url_for('.show_repo', repo_id=local_semirepo_info.ref.id) }}"> {{ _('web_ui.repos.local_packages_semirepo') }} <div class="small-print"> {{ 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..cbe7105 --- /dev/null +++ b/src/hydrilla/proxy/web_ui/templates/repos__add.html.jinja @@ -0,0 +1,62 @@ +{# +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 this code +in a proprietary work, I am not going to enforce this in court. +#} +{% extends "base.html.jinja" %} +{% block title %} {{ _('web_ui.repos.add.title') }} {% endblock %} +{% block main %} + <h3>{{ _('web_ui.repos.add.heading') }}</h3> + <form method="POST"> + <div> + <label for="name_field"> + {{ _('web_ui.repos.add.name_field_label') }} + </label> + </div> + {% if repo_name_invalid is defined -%} + <aside class="error-note"> + {{ _('web_ui.repos.add.repo_name_invalid') }} + </aside> + {%- endif %} + {% if repo_name_taken is defined -%} + <aside class="error-note"> + {{ _('web_ui.repos.add.repo_name_taken') }} + </aside> + {%- endif %} + <div> + <input id="name_field" name="name" required=""> + </div> + <div> + <label for="url_field"> + {{ _('web_ui.repos.add.url_field_label') }} + </label> + </div> + {% if repo_url_invalid is defined -%} + <aside class="error-note"> + {{ _('web_ui.repos.add.repo_url_invalid') }} + </aside> + {%- endif %} + <div> + <input id="url_field" name="url" required=""> + </div> + <div> + <button class="button">{{ _('web_ui.repos.add.submit_button') }}</button> + </div> + </form> +{% endblock %} diff --git a/src/hydrilla/proxy/web_ui/templates/repos__show_single.html.jinja b/src/hydrilla/proxy/web_ui/templates/repos__show_single.html.jinja index 9abb00d..96100ce 100644 --- a/src/hydrilla/proxy/web_ui/templates/repos__show_single.html.jinja +++ b/src/hydrilla/proxy/web_ui/templates/repos__show_single.html.jinja @@ -27,21 +27,19 @@ in a proprietary work, I am not going to enforce this in court. {% include 'include/checkbox_tricks_style.css.jinja' %} {% endblock %} {% block main %} + {% set repo_id = display_info.ref.id -%} <h3> - {% if display_info.ref.id != '1' %} - {{ - _('web_ui.repos.single.heading.name_{}') - .format(display_info.info.long_name) - }} - {% else %} + {% if display_info.is_local_semirepo -%} {{ _('web_ui.repos.local_packages_semirepo') }} - {% endif %} + {% else -%} + {{ _('web_ui.repos.single.heading.name_{}').format(display_info.name) }} + {% endif -%} </h3> - {% if display_info.deleted == True %} + {% if display_info.deleted and not display_info.is_local_semirepo -%} <div> {{ _('web_ui.repos.single.repo_is_deleted') }} </div> - {% elif display_info.deleted == False %} + {% elif not display_info.deleted -%} <input id="show_url_edit_form" type="checkbox" class="chbx-tricks-show-hide" checked=""> <div> @@ -54,7 +52,8 @@ in a proprietary work, I am not going to enforce this in court. </label> </div> </div> - <form method="POST"> + {% set action_url = url_for('.update_repo_url', repo_id=repo_id) -%} + <form method="POST" action="{{ action_url }}"> <input type="hidden" name="action" value="update_url"> <div> <input name="url" value="{{ display_info.url }}"> @@ -69,22 +68,23 @@ in a proprietary work, I am not going to enforce this in court. </div> </form> <div> - {% if display_info.last_refreshed is None %} + {% if display_info.last_refreshed is none -%} {{ _('web_ui.repos.single.repo_never_refreshed') }} - {% else %} + {% else -%} {{ _('web_ui.repos.single.last_refreshed_{}') .format(display_info.last_refreshed.strftime('%F %H:%M')) }} - {% endif %} - <form method="POST"> + {% endif -%} + {% set action_url = url_for('.refresh_repo', repo_id=repo_id) -%} + <form method="POST" action="{{ action_url }}"> <input type="hidden" name="action" value="refresh_repo"> <button class="green-button"> {{ _('web_ui.repos.single.refresh_now_button') }} </button> </form> </div> - {% endif %} + {% endif -%} <div> {{ _('web_ui.repos.item_count_{mappings}_{resources}') @@ -94,4 +94,12 @@ in a proprietary work, I am not going to enforce this in court. ) }} </div> -{% endblock %} + {% if not display_info.is_local_semirepo -%} + {% set action_url = url_for('.remove_repo', repo_id=repo_id) -%} + <form method="POST" action="{{ action_url }}"> + <button class="green-button"> + {{ _('web_ui.repos.single.remove_button') }} + </button> + </form> + {% endif -%} +{% endblock -%} |