summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWojtek Kosior <koszko@koszko.org>2022-08-18 19:18:00 +0200
committerWojtek Kosior <koszko@koszko.org>2022-09-28 12:54:22 +0200
commite1344ae7017b28a54d7714895bd54c8431a20bc6 (patch)
tree66bfcb166a87afa10a0b45100231c102385baf08
parent2579081df2a568192887d776a6965af323b7c4ee (diff)
downloadhaketilo-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.py37
-rw-r--r--src/hydrilla/proxy/state_impl/concrete_state.py4
-rw-r--r--src/hydrilla/proxy/state_impl/prune_packages.py152
-rw-r--r--src/hydrilla/proxy/state_impl/repos.py147
-rw-r--r--src/hydrilla/proxy/tables.sql42
-rw-r--r--src/hydrilla/proxy/web_ui/_app.py5
-rw-r--r--src/hydrilla/proxy/web_ui/packages.py23
-rw-r--r--src/hydrilla/proxy/web_ui/repos.py84
-rw-r--r--src/hydrilla/proxy/web_ui/root.py18
-rw-r--r--src/hydrilla/proxy/web_ui/templates/base.html.jinja6
-rw-r--r--src/hydrilla/proxy/web_ui/templates/include/checkbox_tricks_style.css.jinja4
-rw-r--r--src/hydrilla/proxy/web_ui/templates/packages.html.jinja11
-rw-r--r--src/hydrilla/proxy/web_ui/templates/packages__load_from_disk.html.jinja5
-rw-r--r--src/hydrilla/proxy/web_ui/templates/repos.html.jinja7
-rw-r--r--src/hydrilla/proxy/web_ui/templates/repos__add.html.jinja62
-rw-r--r--src/hydrilla/proxy/web_ui/templates/repos__show_single.html.jinja40
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 -%}