pre-Korves.Net
Signed-off-by: apeters <apeters@korves.net>
This commit is contained in:
commit
1d204f26b8
168
.gitignore
vendored
Normal file
168
.gitignore
vendored
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
system/certs/*
|
||||||
|
database/
|
||||||
|
logs/*
|
||||||
|
misc/*
|
||||||
|
|
||||||
|
.sync
|
||||||
|
rsync.sh
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
25
components/cache.py
Normal file
25
components/cache.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from components.database import IN_MEMORY_DB
|
||||||
|
from components.models import UUID, validate_call
|
||||||
|
from components.utils import ensure_list
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
def buster(object_id: UUID | list[UUID]):
|
||||||
|
object_ids = ensure_list(object_id)
|
||||||
|
for object_id in object_ids:
|
||||||
|
object_id = str(object_id)
|
||||||
|
|
||||||
|
for user_id in IN_MEMORY_DB["OBJECTS_CACHE"]:
|
||||||
|
cached_keys = list(IN_MEMORY_DB["OBJECTS_CACHE"][user_id].keys())
|
||||||
|
if object_id in cached_keys:
|
||||||
|
mapping_name = IN_MEMORY_DB["OBJECTS_CACHE"][user_id][object_id].name
|
||||||
|
if object_id in IN_MEMORY_DB["OBJECTS_CACHE"][user_id]:
|
||||||
|
del IN_MEMORY_DB["OBJECTS_CACHE"][user_id][object_id]
|
||||||
|
|
||||||
|
for user_id in IN_MEMORY_DB["FORM_OPTIONS_CACHE"]:
|
||||||
|
for option in IN_MEMORY_DB["FORM_OPTIONS_CACHE"][user_id].copy():
|
||||||
|
if any(
|
||||||
|
d["value"] == object_id
|
||||||
|
for d in IN_MEMORY_DB["FORM_OPTIONS_CACHE"][user_id][option]
|
||||||
|
):
|
||||||
|
del IN_MEMORY_DB["FORM_OPTIONS_CACHE"][user_id][option]
|
||||||
624
components/cluster/__init__.py
Normal file
624
components/cluster/__init__.py
Normal file
@ -0,0 +1,624 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from config import defaults
|
||||||
|
from contextlib import closing, suppress
|
||||||
|
from components.cluster.exceptions import (
|
||||||
|
IncompleteClusterResponses,
|
||||||
|
DistLockCancelled,
|
||||||
|
UnknownPeer,
|
||||||
|
ZombiePeer,
|
||||||
|
)
|
||||||
|
from components.cluster.cli import cli_processor
|
||||||
|
from components.models.cluster import (
|
||||||
|
CritErrors,
|
||||||
|
Role,
|
||||||
|
FilePath,
|
||||||
|
validate_call,
|
||||||
|
ConnectionStatus,
|
||||||
|
)
|
||||||
|
from components.cluster.leader import elect_leader
|
||||||
|
from components.cluster.peers import Peers
|
||||||
|
from components.cluster.monitor import Monitor
|
||||||
|
from components.cluster.ssl import get_ssl_context
|
||||||
|
from components.logs import logger
|
||||||
|
from components.database import *
|
||||||
|
from components.utils.cryptography import dict_digest_sha1
|
||||||
|
from components.utils.datetimes import ntime_utc_now
|
||||||
|
from components.utils import ensure_list, is_path_within_cwd, chunk_string
|
||||||
|
|
||||||
|
|
||||||
|
class Cluster:
|
||||||
|
def __init__(self, peers, port):
|
||||||
|
self.locks = dict()
|
||||||
|
self.port = port
|
||||||
|
self.receiving = asyncio.Condition()
|
||||||
|
self.tickets = dict()
|
||||||
|
self._partial_tickets = dict()
|
||||||
|
self.monitor = Monitor(self)
|
||||||
|
self.server_limit = 104857600 # 100 MiB
|
||||||
|
self.peers = Peers(peers)
|
||||||
|
self.sending = asyncio.Lock()
|
||||||
|
self._session_patched_tables = dict()
|
||||||
|
|
||||||
|
def _release_tables(self, tables):
|
||||||
|
for t in tables:
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
self.locks[t]["lock"].release()
|
||||||
|
self.locks[t]["ticket"] = None
|
||||||
|
|
||||||
|
async def read_command(self, reader: asyncio.StreamReader) -> tuple[str, str, dict]:
|
||||||
|
bytes_to_read = int.from_bytes(await reader.readexactly(4), "big")
|
||||||
|
input_bytes = await reader.readexactly(bytes_to_read)
|
||||||
|
|
||||||
|
input_decoded = input_bytes.strip().decode("utf-8")
|
||||||
|
data, _, meta = input_decoded.partition(" :META ")
|
||||||
|
ticket, _, cmd = data.partition(" ")
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
r"NAME (?P<name>\S+)",
|
||||||
|
r"SWARM (?P<swarm>\S+)",
|
||||||
|
r"STARTED (?P<started>\S+)",
|
||||||
|
r"LEADER (?P<leader>\S+)",
|
||||||
|
]
|
||||||
|
|
||||||
|
match = re.search(" ".join(patterns), meta)
|
||||||
|
meta_dict = match.groupdict()
|
||||||
|
name = meta_dict["name"]
|
||||||
|
|
||||||
|
if not name in self.peers.remotes:
|
||||||
|
raise UnknownPeer(name)
|
||||||
|
|
||||||
|
self.peers.remotes[name].leader = meta_dict["leader"]
|
||||||
|
self.peers.remotes[name].started = float(meta_dict["started"])
|
||||||
|
self.peers.remotes[name].swarm = meta_dict["swarm"]
|
||||||
|
|
||||||
|
msg = cmd[:150] + (cmd[150:] and "...")
|
||||||
|
logger.debug(f"[← Receiving from {name}][{ticket}] - {msg}")
|
||||||
|
|
||||||
|
return ticket, cmd, meta_dict
|
||||||
|
|
||||||
|
async def incoming_handler(
|
||||||
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||||
|
):
|
||||||
|
monitor_init = False
|
||||||
|
raddr, *_ = writer.get_extra_info("peername")
|
||||||
|
socket, *_ = writer.get_extra_info("socket").getsockname()
|
||||||
|
|
||||||
|
if socket and raddr in defaults.CLUSTER_CLI_BINDINGS:
|
||||||
|
return await cli_processor((reader, writer))
|
||||||
|
|
||||||
|
if raddr not in self.peers.remote_ips():
|
||||||
|
raise UnknownPeer(raddr)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ticket, cmd, peer_meta = await self.read_command(reader)
|
||||||
|
if not monitor_init:
|
||||||
|
self.peers.remotes[peer_meta["name"]].streams._in = (
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
)
|
||||||
|
await self.monitor.start_peer_monitoring(peer_meta["name"])
|
||||||
|
monitor_init = True
|
||||||
|
except (asyncio.exceptions.IncompleteReadError, ConnectionResetError):
|
||||||
|
break
|
||||||
|
except TimeoutError as e:
|
||||||
|
if str(e) == "SSL shutdown timed out":
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
except ZombiePeer:
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.ZOMBIE.response, peer_meta["name"], ticket=ticket
|
||||||
|
)
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
if not self.peers.local.leader and not any(
|
||||||
|
map(
|
||||||
|
lambda s: cmd.startswith(s),
|
||||||
|
["ACK", "STATUS", "FULLTABLE", "INIT"],
|
||||||
|
)
|
||||||
|
):
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.NOT_READY.response, peer_meta["name"], ticket=ticket
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cmd.startswith("PATCHTABLE") or cmd.startswith("FULLTABLE"):
|
||||||
|
_, _, payload = cmd.partition(" ")
|
||||||
|
table_w_hash, table_payload = payload.split(" ")
|
||||||
|
table, table_digest = table_w_hash.split("@")
|
||||||
|
db_params = evaluate_db_params(ticket)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
if not table in db.tables():
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.NO_SUCH_TABLE.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not ticket in self._session_patched_tables:
|
||||||
|
self._session_patched_tables[ticket] = set()
|
||||||
|
|
||||||
|
self._session_patched_tables[ticket].add(table)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cmd.startswith("PATCHTABLE"):
|
||||||
|
table_data = {
|
||||||
|
doc.doc_id: doc for doc in db.table(table).all()
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
local_table_digest = dict_digest_sha1(table_data)
|
||||||
|
if local_table_digest != table_digest:
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.TABLE_HASH_MISMATCH.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
diff = json.loads(base64.b64decode(table_payload))
|
||||||
|
|
||||||
|
for doc_id, docs in diff["changed"].items():
|
||||||
|
a, b = docs
|
||||||
|
c = db.table(table).get(doc_id=doc_id)
|
||||||
|
if c != a:
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.DOC_MISMATCH.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
db.table(table).upsert(
|
||||||
|
Document(b, doc_id=doc_id)
|
||||||
|
)
|
||||||
|
else: # if no break occured, continue
|
||||||
|
for doc_id, doc in diff["added"].items():
|
||||||
|
db.table(table).insert(
|
||||||
|
Document(doc, doc_id=doc_id)
|
||||||
|
)
|
||||||
|
db.table(table).remove(
|
||||||
|
Query().id.one_of(
|
||||||
|
[
|
||||||
|
doc["id"]
|
||||||
|
for doc in diff["removed"].values()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cmd.startswith("FULLTABLE"):
|
||||||
|
insert_data = json.loads(
|
||||||
|
base64.b64decode(table_payload)
|
||||||
|
)
|
||||||
|
db.table(table).truncate()
|
||||||
|
for doc_id, doc in insert_data.items():
|
||||||
|
db.table(table).insert(
|
||||||
|
Document(doc, doc_id=doc_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.send_command(
|
||||||
|
"ACK", peer_meta["name"], ticket=ticket
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.CANNOT_APPLY.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif cmd == "COMMIT":
|
||||||
|
if not ticket in self._session_patched_tables:
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.NOTHING_TO_COMMIT.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
commit_tables = self._session_patched_tables[ticket]
|
||||||
|
del self._session_patched_tables[ticket]
|
||||||
|
await dbcommit(commit_tables, ticket)
|
||||||
|
await self.send_command("ACK", peer_meta["name"], ticket=ticket)
|
||||||
|
|
||||||
|
elif cmd == "STATUS" or cmd == "INIT":
|
||||||
|
await self.send_command("ACK", peer_meta["name"], ticket=ticket)
|
||||||
|
|
||||||
|
elif cmd == "BYE":
|
||||||
|
self.peers.remotes[peer_meta["name"]] = self.peers.remotes[
|
||||||
|
peer_meta["name"]
|
||||||
|
].reset()
|
||||||
|
|
||||||
|
elif cmd.startswith("FILEGET"):
|
||||||
|
_, _, payload = cmd.partition(" ")
|
||||||
|
start, end, file = payload.split(" ")
|
||||||
|
|
||||||
|
if not is_path_within_cwd(file) or not os.path.exists(file):
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.INVALID_FILE_PATH.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if os.stat(file).st_size < int(start):
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.START_BEHIND_FILE_END.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(file, "rb") as f:
|
||||||
|
f.seek(int(start))
|
||||||
|
compressed_data = zlib.compress(f.read(int(end)))
|
||||||
|
compressed_data_encoded = base64.b64encode(
|
||||||
|
compressed_data
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
chunks = chunk_string(f"{file} {compressed_data_encoded}")
|
||||||
|
for idx, c in enumerate(chunks, 1):
|
||||||
|
await self.send_command(
|
||||||
|
f"PACK CHUNKED {idx} {len(chunks)} {c}",
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cmd.startswith("UNLOCK"):
|
||||||
|
_, _, tables_str = cmd.partition(" ")
|
||||||
|
tables = tables_str.split(",")
|
||||||
|
|
||||||
|
for t in tables:
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
self.locks[t]["lock"].release()
|
||||||
|
self.locks[t]["ticket"] = None
|
||||||
|
|
||||||
|
await self.send_command("ACK", peer_meta["name"], ticket=ticket)
|
||||||
|
|
||||||
|
elif cmd.startswith("LOCK"):
|
||||||
|
_, _, tables_str = cmd.partition(" ")
|
||||||
|
tables = tables_str.split(",")
|
||||||
|
|
||||||
|
if peer_meta["swarm"] != self.peers.local.swarm:
|
||||||
|
await self.send_command(
|
||||||
|
CritErrors.PEERS_MISMATCH.response,
|
||||||
|
peer_meta["name"],
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
for t in tables:
|
||||||
|
if t not in self.locks:
|
||||||
|
self.locks[t] = {
|
||||||
|
"lock": asyncio.Lock(),
|
||||||
|
"ticket": None,
|
||||||
|
}
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self.locks[t]["lock"].acquire(),
|
||||||
|
0.3 + random.uniform(0.1, 0.5),
|
||||||
|
)
|
||||||
|
self.locks[t]["ticket"] = ticket
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
await self.send_command(
|
||||||
|
"ACK BUSY", peer_meta["name"], ticket=ticket
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.send_command("ACK", peer_meta["name"], ticket=ticket)
|
||||||
|
|
||||||
|
elif cmd.startswith("PACK"):
|
||||||
|
if not ticket in self._partial_tickets:
|
||||||
|
self._partial_tickets[ticket] = []
|
||||||
|
|
||||||
|
_, _, payload = cmd.partition(" ")
|
||||||
|
_, idx, total, partial_data = payload.split(" ", 3)
|
||||||
|
|
||||||
|
self._partial_tickets[ticket].append(partial_data)
|
||||||
|
if idx == total:
|
||||||
|
self.tickets[ticket].add(
|
||||||
|
(peer_meta["name"], "".join(self._partial_tickets[ticket]))
|
||||||
|
)
|
||||||
|
|
||||||
|
elif cmd.startswith("ACK"):
|
||||||
|
_, _, payload = cmd.partition(" ")
|
||||||
|
if ticket in self.tickets:
|
||||||
|
self.tickets[ticket].add((peer_meta["name"], payload))
|
||||||
|
|
||||||
|
async with self.receiving:
|
||||||
|
self.receiving.notify_all()
|
||||||
|
|
||||||
|
except ConnectionResetError:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def send_command(self, cmd, peers, ticket: str | None = None):
|
||||||
|
if not ticket:
|
||||||
|
ticket = str(ntime_utc_now())
|
||||||
|
|
||||||
|
if peers == "*":
|
||||||
|
peers = self.peers.remotes.keys()
|
||||||
|
else:
|
||||||
|
peers = ensure_list(peers)
|
||||||
|
|
||||||
|
if ticket not in self.tickets:
|
||||||
|
self.tickets[ticket] = set()
|
||||||
|
|
||||||
|
successful_receivers = []
|
||||||
|
|
||||||
|
for name in peers:
|
||||||
|
async with self.peers.remotes[name].sending_lock:
|
||||||
|
con, status = await self.peers.remotes[name].connect()
|
||||||
|
if con:
|
||||||
|
reader, writer = con
|
||||||
|
buffer_data = [
|
||||||
|
ticket,
|
||||||
|
cmd,
|
||||||
|
":META",
|
||||||
|
f"NAME {self.peers.local.name}",
|
||||||
|
"SWARM {swarm}".format(
|
||||||
|
swarm=self.peers.local.swarm or "?CONFUSED"
|
||||||
|
),
|
||||||
|
f"STARTED {self.peers.local.started}",
|
||||||
|
"LEADER {leader}".format(
|
||||||
|
leader=self.peers.local.leader or "?CONFUSED"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
buffer_bytes = " ".join(buffer_data).encode("utf-8")
|
||||||
|
writer.write(len(buffer_bytes).to_bytes(4, "big"))
|
||||||
|
writer.write(buffer_bytes)
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
msg = cmd[:150] + (cmd[150:] and "...")
|
||||||
|
logger.debug(f"[→ Sending to {name}][{ticket}] - {msg}")
|
||||||
|
|
||||||
|
successful_receivers.append(name)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Cannot send to peer {name} - {status}")
|
||||||
|
|
||||||
|
return ticket, successful_receivers
|
||||||
|
|
||||||
|
async def release(self, tables: list = ["main"]) -> str:
|
||||||
|
CTX_TICKET.reset(self._ctx_ticket)
|
||||||
|
|
||||||
|
if self.peers.local.role == Role.FOLLOWER:
|
||||||
|
try:
|
||||||
|
ticket, receivers = await self.send_command(
|
||||||
|
f"UNLOCK {','.join(tables)}",
|
||||||
|
self.peers.local.leader,
|
||||||
|
)
|
||||||
|
async with self.receiving:
|
||||||
|
ret, responses = await self.await_receivers(
|
||||||
|
ticket, receivers, raise_err=True
|
||||||
|
)
|
||||||
|
except IncompleteClusterResponses:
|
||||||
|
self._release_tables(tables)
|
||||||
|
raise DistLockCancelled(
|
||||||
|
"Leader did not respond properly to unlock request"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._release_tables(tables)
|
||||||
|
|
||||||
|
async def acquire_lock(self, tables: list = ["main"]) -> str:
|
||||||
|
try:
|
||||||
|
ticket = str(ntime_utc_now())
|
||||||
|
for t in tables:
|
||||||
|
if t not in self.locks:
|
||||||
|
self.locks[t] = {
|
||||||
|
"lock": asyncio.Lock(),
|
||||||
|
"ticket": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncio.wait_for(self.locks[t]["lock"].acquire(), 20.0)
|
||||||
|
self.locks[t]["ticket"] = ticket
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
raise DistLockCancelled("Unable to acquire local lock")
|
||||||
|
|
||||||
|
if self.peers.local.role == Role.FOLLOWER:
|
||||||
|
try:
|
||||||
|
if not self.peers.local.leader:
|
||||||
|
self._release_tables(tables)
|
||||||
|
raise IncompleteClusterResponses("Cluster is not ready")
|
||||||
|
|
||||||
|
ticket, receivers = await self.send_command(
|
||||||
|
f"LOCK {','.join(tables)}", self.peers.local.leader
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self.receiving:
|
||||||
|
result, responses = await self.await_receivers(
|
||||||
|
ticket, receivers, raise_err=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if "BUSY" in responses:
|
||||||
|
self._release_tables(tables)
|
||||||
|
return await self.acquire_lock(tables)
|
||||||
|
|
||||||
|
elif not result:
|
||||||
|
self._release_tables(tables)
|
||||||
|
if CritErrors.PEERS_MISMATCH in responses:
|
||||||
|
raise DistLockCancelled(
|
||||||
|
"Leader rejected lock due to member inconsistency"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise DistLockCancelled(
|
||||||
|
"Cannot acquire lock from leader, trying a re-election"
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self._release_tables(tables)
|
||||||
|
raise DistLockCancelled("Application raised CancelledError")
|
||||||
|
|
||||||
|
self._ctx_ticket = CTX_TICKET.set(ticket)
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def request_files(
|
||||||
|
self,
|
||||||
|
files: FilePath | list[FilePath],
|
||||||
|
peers: str | list,
|
||||||
|
start: int = 0,
|
||||||
|
end: int = -1,
|
||||||
|
):
|
||||||
|
assert self.locks["files"]["lock"].locked()
|
||||||
|
for file in ensure_list(files):
|
||||||
|
try:
|
||||||
|
if not is_path_within_cwd(file):
|
||||||
|
logger.error(f"File not within cwd: {file}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for peer in ensure_list(peers):
|
||||||
|
peer_start = start
|
||||||
|
peer_end = end
|
||||||
|
assert peer in self.peers.remotes
|
||||||
|
if peer_start == -1:
|
||||||
|
if os.path.exists(f"peer_files/{peer}/{file}"):
|
||||||
|
peer_start = os.stat(f"peer_files/{peer}/{file}").st_size
|
||||||
|
else:
|
||||||
|
peer_start = 0
|
||||||
|
|
||||||
|
ticket, receivers = await self.send_command(
|
||||||
|
f"FILEGET {peer_start} {peer_end} {file}", peer
|
||||||
|
)
|
||||||
|
async with self.receiving:
|
||||||
|
_, response = await self.await_receivers(
|
||||||
|
ticket, receivers, raise_err=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in response:
|
||||||
|
r_file, r_data = r.split(" ")
|
||||||
|
assert FilePath(r_file) == file
|
||||||
|
file_dest = f"peer_files/{peer}/{file}"
|
||||||
|
os.makedirs(os.path.dirname(file_dest), exist_ok=True)
|
||||||
|
payload = zlib.decompress(base64.b64decode(r_data))
|
||||||
|
if os.path.exists(file_dest):
|
||||||
|
mode = "r+b"
|
||||||
|
else:
|
||||||
|
mode = "w+b"
|
||||||
|
with open(file_dest, mode) as f:
|
||||||
|
f.seek(peer_start)
|
||||||
|
f.write(payload)
|
||||||
|
|
||||||
|
except IncompleteClusterResponses:
|
||||||
|
logger.error(f"Sending command to peers '{peers}' failed")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unhandled error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def run(self, shutdown_trigger) -> None:
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
self.incoming_handler,
|
||||||
|
self.peers.local._all_bindings_as_str,
|
||||||
|
self.port,
|
||||||
|
ssl=get_ssl_context("server"),
|
||||||
|
limit=self.server_limit,
|
||||||
|
)
|
||||||
|
self._shutdown = shutdown_trigger
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Listening on {self.port} on address {' and '.join(self.peers.local._all_bindings_as_str)}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
for name in self.peers.remotes:
|
||||||
|
ip, status = self.peers.remotes[name]._eval_ip()
|
||||||
|
if ip:
|
||||||
|
con, status = await self.peers.remotes[name].connect(ip)
|
||||||
|
if con:
|
||||||
|
ticket, receivers = await self.send_command("INIT", name)
|
||||||
|
async with self.receiving:
|
||||||
|
ret, responses = await self.await_receivers(
|
||||||
|
ticket, receivers, raise_err=False
|
||||||
|
)
|
||||||
|
if CritErrors.ZOMBIE in responses:
|
||||||
|
logger.critical(
|
||||||
|
f"Peer {name} has not yet disconnected a previous session: {status}"
|
||||||
|
)
|
||||||
|
shutdown_trigger.set()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.debug(f"Not sending INIT to peer {name}: {status}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Not sending INIT to peer {name}: {status}")
|
||||||
|
|
||||||
|
t = asyncio.create_task(self.monitor._ticket_worker(), name="tickets")
|
||||||
|
self.monitor.tasks.add(t)
|
||||||
|
t.add_done_callback(self.monitor.tasks.discard)
|
||||||
|
|
||||||
|
try:
|
||||||
|
binds = [s.getsockname()[0] for s in server.sockets]
|
||||||
|
for local_rbind in self.peers.local._bindings_as_str:
|
||||||
|
if local_rbind not in binds:
|
||||||
|
logger.critical(
|
||||||
|
f"Could not bind requested address {local_rbind}"
|
||||||
|
)
|
||||||
|
shutdown_trigger.set()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await shutdown_trigger.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if self.peers.get_established():
|
||||||
|
await self.send_command("BYE", "*")
|
||||||
|
|
||||||
|
[t.cancel() for t in self.monitor.tasks]
|
||||||
|
|
||||||
|
async def await_receivers(
|
||||||
|
self,
|
||||||
|
ticket,
|
||||||
|
receivers,
|
||||||
|
raise_err: bool,
|
||||||
|
timeout: float = defaults.CLUSTER_PEERS_TIMEOUT * len(defaults.CLUSTER_PEERS),
|
||||||
|
):
|
||||||
|
errors = []
|
||||||
|
missing_receivers = []
|
||||||
|
|
||||||
|
assert self.receiving.locked()
|
||||||
|
|
||||||
|
if timeout >= defaults.CLUSTER_PEERS_TIMEOUT * len(defaults.CLUSTER_PEERS) + 10:
|
||||||
|
raise ValueError("Timeout is too high")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not all(
|
||||||
|
r in [peer for peer, _ in self.tickets[ticket]] for r in receivers
|
||||||
|
):
|
||||||
|
await asyncio.wait_for(self.receiving.wait(), timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
missing_receivers = [
|
||||||
|
r
|
||||||
|
for r in receivers
|
||||||
|
if r not in [peer for peer, _ in self.tickets[ticket]]
|
||||||
|
]
|
||||||
|
errors.append(
|
||||||
|
f"Timeout waiting for receviers: {', '.join(missing_receivers)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
responses = []
|
||||||
|
|
||||||
|
for peer, response in self.tickets[ticket]:
|
||||||
|
if response in CritErrors._value2member_map_:
|
||||||
|
responses.append(CritErrors(response))
|
||||||
|
errors.append(f"CRIT response from {peer}: {CritErrors(response)}")
|
||||||
|
else:
|
||||||
|
responses.append(response)
|
||||||
|
|
||||||
|
if not missing_receivers and len(responses) != len(receivers):
|
||||||
|
errors.append("Unplausible amount of responses for ticket")
|
||||||
|
|
||||||
|
self.tickets.pop(ticket, None)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
logger.error("\n".join(errors))
|
||||||
|
if raise_err:
|
||||||
|
raise IncompleteClusterResponses("\n".join(errors))
|
||||||
|
else:
|
||||||
|
logger.success(f"{ticket} OK")
|
||||||
|
|
||||||
|
return not errors, responses
|
||||||
57
components/cluster/cli.py
Normal file
57
components/cluster/cli.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
async def cli_processor(streams: tuple[asyncio.StreamReader, asyncio.StreamWriter]):
|
||||||
|
from components.users import what_id, get
|
||||||
|
from components.database import IN_MEMORY_DB
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader, writer = streams
|
||||||
|
while not reader.at_eof():
|
||||||
|
cmd = await reader.readexactly(1)
|
||||||
|
if cmd == b"\x97":
|
||||||
|
data = await reader.readuntil(b"\n")
|
||||||
|
login = data.strip().decode("utf-8")
|
||||||
|
try:
|
||||||
|
user_id = await what_id(login=login)
|
||||||
|
user = await get(user_id=user_id)
|
||||||
|
if "system" not in user.acl:
|
||||||
|
IN_MEMORY_DB["PROMOTE_USERS"].add(user_id)
|
||||||
|
writer.write(b"\x01")
|
||||||
|
else:
|
||||||
|
writer.write(b"\x02")
|
||||||
|
except Exception as e:
|
||||||
|
writer.write(b"\x03")
|
||||||
|
await writer.drain()
|
||||||
|
elif cmd == b"\x98":
|
||||||
|
awaiting = dict()
|
||||||
|
idx = 1
|
||||||
|
for k, v in IN_MEMORY_DB.items():
|
||||||
|
if (
|
||||||
|
isinstance(v, dict)
|
||||||
|
and v.get("token_type") == "cli_confirmation"
|
||||||
|
):
|
||||||
|
awaiting[idx] = (k, v["intention"])
|
||||||
|
idx += 1
|
||||||
|
writer.write(f"{json.dumps(awaiting)}\n".encode("ascii"))
|
||||||
|
await writer.drain()
|
||||||
|
elif cmd == b"\x99":
|
||||||
|
data = await reader.readexactly(14)
|
||||||
|
confirmed = data.strip().decode("ascii")
|
||||||
|
code = "%06d" % random.randint(0, 999999)
|
||||||
|
IN_MEMORY_DB.get(confirmed, {}).update(
|
||||||
|
{"status": "confirmed", "code": code}
|
||||||
|
)
|
||||||
|
writer.write(f"{code}\n".encode("ascii"))
|
||||||
|
await writer.drain()
|
||||||
|
except Exception as e:
|
||||||
|
if type(e) not in [
|
||||||
|
asyncio.exceptions.IncompleteReadError,
|
||||||
|
ConnectionResetError,
|
||||||
|
]:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
4
components/cluster/cluster.py
Normal file
4
components/cluster/cluster.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from config.defaults import CLUSTER_PEERS
|
||||||
|
from components.cluster import Cluster
|
||||||
|
|
||||||
|
cluster = Cluster(peers=CLUSTER_PEERS, port=2102)
|
||||||
23
components/cluster/exceptions.py
Normal file
23
components/cluster/exceptions.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
class DistLockCancelled(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IncompleteClusterResponses(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ZombiePeer(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownPeer(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterHTTPException(HTTPException):
|
||||||
|
def __init__(self, description=None):
|
||||||
|
super().__init__(description)
|
||||||
|
self.code = 999
|
||||||
60
components/cluster/leader.py
Normal file
60
components/cluster/leader.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from components.logs import logger
|
||||||
|
from components.models.cluster import Role
|
||||||
|
|
||||||
|
|
||||||
|
def elect_leader(peers: "Peers") -> None:
|
||||||
|
def _destroy():
|
||||||
|
peers.local.leader = None
|
||||||
|
peers.local.role = Role.FOLLOWER
|
||||||
|
peers.local.swarm = ""
|
||||||
|
|
||||||
|
n_eligible_peers = len(peers.get_established(include_local=True))
|
||||||
|
n_all_peers = len(peers.remotes) + 1 # + self
|
||||||
|
|
||||||
|
if not (n_eligible_peers >= (51 / 100) * n_all_peers):
|
||||||
|
logger.info("Cannot elect leader node, not enough peers")
|
||||||
|
_destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
leader, started = min(
|
||||||
|
(
|
||||||
|
(peer, peer_data.started)
|
||||||
|
for peer, peer_data in peers.remotes.items()
|
||||||
|
if peer_data._fully_established
|
||||||
|
),
|
||||||
|
key=lambda x: x[1],
|
||||||
|
default=(None, float("inf")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if peers.local.started < started:
|
||||||
|
if peers.local.leader != peers.local.name:
|
||||||
|
logger.info("This node has been elected as the leader.")
|
||||||
|
peers.local.leader = peers.local.name
|
||||||
|
peers.local.role = Role.LEADER
|
||||||
|
|
||||||
|
else:
|
||||||
|
if peers.remotes[leader].leader == "?CONFUSED":
|
||||||
|
_destroy()
|
||||||
|
logger.info(
|
||||||
|
f"""Potential leader node '{leader}' is still in the
|
||||||
|
election process or confused; waiting."""
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if peers.remotes[leader].leader != leader:
|
||||||
|
_destroy()
|
||||||
|
logger.warning(
|
||||||
|
f"Potential leader node '{leader}' reports a different leader; waiting"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if peers.local.leader != leader:
|
||||||
|
peers.local.leader = leader
|
||||||
|
peers.local.role = Role.FOLLOWER
|
||||||
|
logger.info(f"Elected node '{leader}' as the leader")
|
||||||
|
|
||||||
|
if peers.local.leader:
|
||||||
|
peers.local.swarm = ";".join(peers.get_established(include_local=True))
|
||||||
|
peers.local.swarm_complete = n_eligible_peers == n_all_peers
|
||||||
|
|
||||||
|
logger.debug(f"Cluster size {n_eligible_peers}/{n_all_peers}")
|
||||||
196
components/cluster/monitor.py
Normal file
196
components/cluster/monitor.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from config import defaults
|
||||||
|
from components.logs import logger
|
||||||
|
from components.utils.datetimes import ntime_utc_now
|
||||||
|
from components.cluster.leader import elect_leader
|
||||||
|
from components.cluster.exceptions import ZombiePeer
|
||||||
|
|
||||||
|
|
||||||
|
class Monitor:
|
||||||
|
def __init__(self, cluster_instance: "Cluster"):
|
||||||
|
self.cluster_instance = cluster_instance
|
||||||
|
self.tasks = set()
|
||||||
|
|
||||||
|
async def _ticket_worker(self):
|
||||||
|
"""
|
||||||
|
This asynchronous method checks for locks on tables and releases them if existent. It also deletes the tickets from
|
||||||
|
the cluster instance, which have exceeded the timeout threshold. It performs these checks every 10 seconds until
|
||||||
|
a shutdown is set.
|
||||||
|
|
||||||
|
There are no input parameters for this function and it does not return anything. It gives an output through logs or errors.
|
||||||
|
"""
|
||||||
|
while not self.cluster_instance._shutdown.is_set():
|
||||||
|
for table in self.cluster_instance.locks:
|
||||||
|
ticket = self.cluster_instance.locks[table]["ticket"]
|
||||||
|
table_locked = self.cluster_instance.locks[table]["lock"].locked()
|
||||||
|
|
||||||
|
if (table_locked and not ticket) or (
|
||||||
|
ntime_utc_now() - float(ticket or "inf")
|
||||||
|
) > 20.0:
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
self.cluster_instance.locks[table]["lock"].release()
|
||||||
|
self.cluster_instance.locks[table]["ticket"] = None
|
||||||
|
logger.error(
|
||||||
|
f"Force release of table '{table}': "
|
||||||
|
+ f"Ticket: {ticket} / Lock status: {table_locked}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for t in self.cluster_instance.tickets.copy():
|
||||||
|
if ntime_utc_now() - float(t) > (
|
||||||
|
defaults.CLUSTER_PEERS_TIMEOUT * len(defaults.CLUSTER_PEERS) + 10
|
||||||
|
):
|
||||||
|
with suppress(KeyError):
|
||||||
|
del self.cluster_instance.tickets[t]
|
||||||
|
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
async def _cleanup_peer_connection(self, peer):
|
||||||
|
"""
|
||||||
|
Clean up a peer connection. Warning logs are created for the peer removal. The cluster instance of peers is reset
|
||||||
|
and if the shutdown is not set, leadership is elected and commands are sent to non-local peers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
peer: the peer for whom the connection needs to be cleaned up.
|
||||||
|
"""
|
||||||
|
logger.warning(f"Removing {peer}")
|
||||||
|
self.cluster_instance.peers.remotes[peer] = self.cluster_instance.peers.remotes[
|
||||||
|
peer
|
||||||
|
].reset()
|
||||||
|
if not self.cluster_instance._shutdown.is_set():
|
||||||
|
async with self.cluster_instance.receiving:
|
||||||
|
elect_leader(self.cluster_instance.peers)
|
||||||
|
if self.cluster_instance.peers.local.swarm != "":
|
||||||
|
for p in self.cluster_instance.peers.local.swarm.split(";"):
|
||||||
|
if p != self.cluster_instance.peers.local.name:
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
ticket,
|
||||||
|
receivers,
|
||||||
|
) = await self.cluster_instance.send_command(
|
||||||
|
"STATUS",
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _peer_worker(self, name):
|
||||||
|
"""
|
||||||
|
The method works with the name as an input and evaluates the stream for the same. It also handles leader election
|
||||||
|
and various exception handling regarding connection reset and timeouts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: the name for whom the peer works.
|
||||||
|
"""
|
||||||
|
ireader, iwriter = self.cluster_instance.peers.remotes[name].streams._in
|
||||||
|
timeout_c = 0
|
||||||
|
c = -1
|
||||||
|
|
||||||
|
logger.info(f"Evaluating stream for {name}")
|
||||||
|
while not name in self.cluster_instance.peers.get_established():
|
||||||
|
await asyncio.sleep(0.125)
|
||||||
|
|
||||||
|
oreader, owriter = self.cluster_instance.peers.remotes[name].streams.out
|
||||||
|
|
||||||
|
elect_leader(self.cluster_instance.peers)
|
||||||
|
|
||||||
|
while True and timeout_c < 3:
|
||||||
|
try:
|
||||||
|
assert not all(
|
||||||
|
[
|
||||||
|
oreader.at_eof(),
|
||||||
|
ireader.at_eof(),
|
||||||
|
iwriter.is_closing(),
|
||||||
|
owriter.is_closing(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with asyncio.timeout(defaults.CLUSTER_PEERS_TIMEOUT * 3):
|
||||||
|
iwriter.write(b"\x11")
|
||||||
|
await iwriter.drain()
|
||||||
|
res = await oreader.readexactly(1)
|
||||||
|
assert res == b"\x11"
|
||||||
|
|
||||||
|
timeout_c = 0
|
||||||
|
c += 0.25
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
if not c % 5:
|
||||||
|
if (
|
||||||
|
not self.cluster_instance.peers.local.leader
|
||||||
|
or not self.cluster_instance.peers.local.swarm_complete
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
ticket,
|
||||||
|
receivers,
|
||||||
|
) = await self.cluster_instance.send_command(
|
||||||
|
"STATUS",
|
||||||
|
"*"
|
||||||
|
if self.cluster_instance.peers.local.leader
|
||||||
|
and not self.cluster_instance.peers.local.swarm_complete
|
||||||
|
else name,
|
||||||
|
)
|
||||||
|
except ConnectionResetError:
|
||||||
|
break
|
||||||
|
|
||||||
|
async with self.cluster_instance.receiving:
|
||||||
|
elect_leader(self.cluster_instance.peers)
|
||||||
|
await self.cluster_instance.await_receivers(
|
||||||
|
ticket, receivers, raise_err=False, timeout=3
|
||||||
|
)
|
||||||
|
c = 0
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
timeout_c += 1
|
||||||
|
continue
|
||||||
|
except (
|
||||||
|
AssertionError,
|
||||||
|
ConnectionResetError,
|
||||||
|
asyncio.exceptions.IncompleteReadError,
|
||||||
|
):
|
||||||
|
logger.error(f"Peer {name} failed")
|
||||||
|
break
|
||||||
|
|
||||||
|
if c != -1:
|
||||||
|
try:
|
||||||
|
iwriter.close()
|
||||||
|
async with asyncio.timeout(0.1):
|
||||||
|
await iwriter.wait_closed()
|
||||||
|
except (ConnectionResetError, TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
owriter.close()
|
||||||
|
async with asyncio.timeout(0.1):
|
||||||
|
await owriter.wait_closed()
|
||||||
|
await owriter.wait_closed()
|
||||||
|
except (ConnectionResetError, TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_task_done(self, task: asyncio.Task):
|
||||||
|
"""
|
||||||
|
Called when an asyncio Task is done. A cleanup peer connection task is created and the original task is removed
|
||||||
|
from the set of tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The task that was completed.
|
||||||
|
"""
|
||||||
|
asyncio.create_task(self._cleanup_peer_connection(task.get_name()))
|
||||||
|
self.tasks.discard(task)
|
||||||
|
|
||||||
|
async def start_peer_monitoring(self, name):
|
||||||
|
"""
|
||||||
|
Start monitoring a peer. If the name of the peer is already in the list of tasks, it raises a ZombiePeer exception
|
||||||
|
and otherwise, the peer worker is created as a task with the peer’s name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: the name of the peer to monitor.
|
||||||
|
"""
|
||||||
|
if name in [task.get_name() for task in self.tasks]:
|
||||||
|
raise ZombiePeer(name)
|
||||||
|
|
||||||
|
t = asyncio.create_task(self._peer_worker(name), name=name)
|
||||||
|
self.tasks.add(t)
|
||||||
|
t.add_done_callback(self._on_task_done)
|
||||||
71
components/cluster/peers.py
Normal file
71
components/cluster/peers.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from components.logs import logger
|
||||||
|
from components.models.cluster import LocalPeer, RemotePeer
|
||||||
|
from components.utils import ensure_list
|
||||||
|
|
||||||
|
|
||||||
|
class Peers:
|
||||||
|
def __init__(self, peers):
|
||||||
|
self.remotes = dict()
|
||||||
|
|
||||||
|
for peer in peers:
|
||||||
|
if not peer.get("is_self", False):
|
||||||
|
_peer = RemotePeer(**peer)
|
||||||
|
self.remotes[_peer.name] = _peer
|
||||||
|
|
||||||
|
self.local = LocalPeer(**next(peer for peer in peers if peer.get("is_self")))
|
||||||
|
self._peers = peers
|
||||||
|
|
||||||
|
def get_offline_peers(self):
|
||||||
|
return [p for p in self.remotes if p not in self.get_established()]
|
||||||
|
|
||||||
|
def get_established(self, names_only: bool = True, include_local: bool = False):
|
||||||
|
peers = []
|
||||||
|
for peer, peer_data in self.remotes.items():
|
||||||
|
if peer_data._fully_established == True:
|
||||||
|
if names_only:
|
||||||
|
peers.append(peer_data.name)
|
||||||
|
else:
|
||||||
|
peers.append(peer_data)
|
||||||
|
|
||||||
|
if include_local:
|
||||||
|
if names_only:
|
||||||
|
peers.append(self.local.name)
|
||||||
|
else:
|
||||||
|
peers.append(self.local)
|
||||||
|
|
||||||
|
if names_only:
|
||||||
|
return sorted(peers)
|
||||||
|
return sorted(peers, key=lambda peer: peer.name)
|
||||||
|
|
||||||
|
def get_remote_peer_ip(
|
||||||
|
self, name: str, ip_version: str | list = ["ip4", "ip6", "nat_ip4"]
|
||||||
|
):
|
||||||
|
def _select_best_ip(peer):
|
||||||
|
return (
|
||||||
|
str(getattr(peer, "ip4"))
|
||||||
|
or str(getattr(peer, "ip6"))
|
||||||
|
or str(getattr(peer, "nat_ip4"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_ips(peer, ip_version):
|
||||||
|
return [
|
||||||
|
str(ip) for key in ensure_list(ip_version) if (ip := getattr(peer, key))
|
||||||
|
]
|
||||||
|
|
||||||
|
peer = self.remotes.get(name)
|
||||||
|
if peer:
|
||||||
|
if ip_version == "best":
|
||||||
|
return _select_best_ip(peer)
|
||||||
|
return _get_ips(peer, ip_version)
|
||||||
|
|
||||||
|
def remote_ips(self):
|
||||||
|
for name in self.remotes:
|
||||||
|
for ip in self.get_remote_peer_ip(
|
||||||
|
name=name, ip_version=["ip4", "ip6", "nat_ip4"]
|
||||||
|
):
|
||||||
|
yield ip
|
||||||
|
|
||||||
|
def get_remote_peer_name(self, ip):
|
||||||
|
for peer_data in self.remotes.values():
|
||||||
|
if ip in peer_data._all_ips_as_str:
|
||||||
|
return peer_data.name
|
||||||
21
components/cluster/ssl.py
Normal file
21
components/cluster/ssl.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import ssl
|
||||||
|
from components.logs import logger
|
||||||
|
from config import defaults
|
||||||
|
|
||||||
|
|
||||||
|
def get_ssl_context(type_value: str):
|
||||||
|
if type_value == "client":
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
elif type_value == "server":
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown type_value")
|
||||||
|
|
||||||
|
context.load_cert_chain(
|
||||||
|
certfile=defaults.TLS_CERTFILE, keyfile=defaults.TLS_KEYFILE
|
||||||
|
)
|
||||||
|
context.load_verify_locations(cafile=defaults.TLS_CA)
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.VerifyMode.CERT_REQUIRED
|
||||||
|
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
return context
|
||||||
18
components/logs/__init__.py
Normal file
18
components/logs/__init__.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from components.logs import log
|
||||||
|
from config.defaults import (
|
||||||
|
LOG_LEVEL,
|
||||||
|
LOG_FILE_ROTATION,
|
||||||
|
LOG_FILE_RETENTION,
|
||||||
|
CLUSTER_PEERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = log.Logger()
|
||||||
|
logger.add(
|
||||||
|
f"logs/application.log",
|
||||||
|
level=LOG_LEVEL,
|
||||||
|
colorize=False,
|
||||||
|
max_size_mb=LOG_FILE_ROTATION,
|
||||||
|
retention=LOG_FILE_RETENTION,
|
||||||
|
text=lambda _: next(p["name"] for p in CLUSTER_PEERS if p.get("is_self")),
|
||||||
|
serialize=True,
|
||||||
|
)
|
||||||
124
components/logs/log.py
Normal file
124
components/logs/log.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from components.utils.datetimes import datetime, timedelta
|
||||||
|
|
||||||
|
SUCCESS_LEVEL = 25
|
||||||
|
CRITICAL_LEVEL = 50
|
||||||
|
logging.addLevelName(SUCCESS_LEVEL, "SUCCESS")
|
||||||
|
logging.addLevelName(CRITICAL_LEVEL, "CRITICAL")
|
||||||
|
|
||||||
|
LOG_COLORS = {
|
||||||
|
"DEBUG": "\033[94m",
|
||||||
|
"INFO": "\033[96m",
|
||||||
|
"SUCCESS": "\033[92m",
|
||||||
|
"WARNING": "\033[93m",
|
||||||
|
"ERROR": "\033[91m",
|
||||||
|
"CRITICAL": "\033[95m",
|
||||||
|
"RESET": "\033[0m",
|
||||||
|
"BOLD": "\033[1m",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
def __init__(self, text):
|
||||||
|
super().__init__()
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
exc_text = None
|
||||||
|
if record.exc_info:
|
||||||
|
exc_text = traceback.format_exc()
|
||||||
|
|
||||||
|
log_entry = {
|
||||||
|
"text": self.text,
|
||||||
|
"record": {
|
||||||
|
"elapsed": {
|
||||||
|
"repr": str(timedelta(seconds=record.relativeCreated / 1000)),
|
||||||
|
"seconds": record.relativeCreated / 1000,
|
||||||
|
},
|
||||||
|
"exception": exc_text,
|
||||||
|
"extra": record.__dict__.get("extra", {}),
|
||||||
|
"file": {"name": record.filename, "path": record.pathname},
|
||||||
|
"function": record.funcName,
|
||||||
|
"level": {
|
||||||
|
"icon": "✅"
|
||||||
|
if record.levelno == SUCCESS_LEVEL
|
||||||
|
else "ℹ️"
|
||||||
|
if record.levelno == logging.INFO
|
||||||
|
else "⚠️",
|
||||||
|
"name": record.levelname,
|
||||||
|
"no": record.levelno,
|
||||||
|
},
|
||||||
|
"line": record.lineno,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"module": record.module,
|
||||||
|
"name": record.name,
|
||||||
|
"process": {"id": record.process, "name": record.processName},
|
||||||
|
"thread": {"id": record.thread, "name": record.threadName},
|
||||||
|
"time": {
|
||||||
|
"repr": datetime.utcfromtimestamp(record.created).isoformat()
|
||||||
|
+ "+00:00",
|
||||||
|
"timestamp": record.created,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return json.dumps(log_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class PlainTextFormatter(logging.Formatter):
|
||||||
|
def format(self, record):
|
||||||
|
log_time = datetime.utcfromtimestamp(record.created).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S.%f"
|
||||||
|
)[:-3]
|
||||||
|
level_color = LOG_COLORS.get(record.levelname, LOG_COLORS["RESET"])
|
||||||
|
message_bold = f"{LOG_COLORS['BOLD']}{record.getMessage()}{LOG_COLORS['RESET']}"
|
||||||
|
return f"{log_time} | {LOG_COLORS['BOLD']}{level_color}{record.levelname:<8}{LOG_COLORS['RESET']} | {record.funcName}:{record.lineno} - {message_bold}"
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger("custom_logger")
|
||||||
|
self.logger.setLevel(logging.DEBUG)
|
||||||
|
stdout_handler = logging.StreamHandler()
|
||||||
|
stdout_handler.setLevel(logging.DEBUG)
|
||||||
|
stdout_handler.setFormatter(PlainTextFormatter())
|
||||||
|
self.logger.addHandler(stdout_handler)
|
||||||
|
|
||||||
|
def add(self, filepath, level, colorize, max_size_mb, retention, text, serialize):
|
||||||
|
for handler in self.logger.handlers[:]:
|
||||||
|
if isinstance(handler, RotatingFileHandler):
|
||||||
|
self.logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
filepath,
|
||||||
|
maxBytes=max_size_mb * 1024 * 1024,
|
||||||
|
backupCount=retention,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setLevel(level)
|
||||||
|
handler.setFormatter(JSONFormatter(text(None)))
|
||||||
|
self.logger.addHandler(handler)
|
||||||
|
|
||||||
|
def log(self, level, message):
|
||||||
|
self.logger.log(level, message, stacklevel=2)
|
||||||
|
|
||||||
|
def info(self, message):
|
||||||
|
self.logger.info(message, stacklevel=2)
|
||||||
|
|
||||||
|
def warning(self, message):
|
||||||
|
self.logger.warning(message, stacklevel=2)
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
self.logger.error(message, stacklevel=2)
|
||||||
|
|
||||||
|
def debug(self, message):
|
||||||
|
self.logger.debug(message, stacklevel=2)
|
||||||
|
|
||||||
|
def success(self, message):
|
||||||
|
self.logger.log(SUCCESS_LEVEL, message, stacklevel=2)
|
||||||
|
|
||||||
|
def critical(self, message):
|
||||||
|
self.logger.log(CRITICAL_LEVEL, message, exc_info=True, stacklevel=2)
|
||||||
26
components/models/__init__.py
Normal file
26
components/models/__init__.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import email_validator
|
||||||
|
from pydantic import (
|
||||||
|
AfterValidator,
|
||||||
|
BaseModel,
|
||||||
|
BeforeValidator,
|
||||||
|
computed_field,
|
||||||
|
ConfigDict,
|
||||||
|
conint,
|
||||||
|
constr,
|
||||||
|
Field,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
FilePath,
|
||||||
|
model_validator,
|
||||||
|
TypeAdapter,
|
||||||
|
validate_call,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from pydantic.networks import IPvAnyAddress
|
||||||
|
from pydantic_core import PydanticCustomError
|
||||||
|
from typing import List, Dict, Any, Literal, Annotated, Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
email_validator.TEST_ENVIRONMENT = True
|
||||||
|
validate_email = email_validator.validate_email
|
||||||
193
components/models/cluster.py
Normal file
193
components/models/cluster.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from components.cluster.ssl import get_ssl_context
|
||||||
|
from components.models import *
|
||||||
|
from components.utils.datetimes import ntime_utc_now
|
||||||
|
from config import defaults
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Enum):
|
||||||
|
LEADER = 1
|
||||||
|
FOLLOWER = 2
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionStatus(Enum):
|
||||||
|
CONNECTED = 0
|
||||||
|
REFUSED = 1
|
||||||
|
SOCKET_REFUSED = 2
|
||||||
|
ALL_AVAILABLE_FAILED = 3
|
||||||
|
OK = 4
|
||||||
|
OK_WITH_PREVIOUS_ERRORS = 5
|
||||||
|
|
||||||
|
|
||||||
|
class CritErrors(Enum):
|
||||||
|
NOT_READY = "CRIT:NOT_READY"
|
||||||
|
NO_SUCH_TABLE = "CRIT:NO_SUCH_TABLE"
|
||||||
|
TABLE_HASH_MISMATCH = "CRIT:TABLE_HASH_MISMATCH"
|
||||||
|
CANNOT_APPLY = "CRIT:CANNOT_APPLY"
|
||||||
|
NOTHING_TO_COMMIT = "CRIT:NOTHING_TO_COMMIT"
|
||||||
|
INVALID_FILE_PATH = "CRIT:INVALID_FILE_PATH"
|
||||||
|
START_BEHIND_FILE_END = "CRIT:START_BEHIND_FILE_END"
|
||||||
|
PEERS_MISMATCH = "CRIT:PEERS_MISMATCH"
|
||||||
|
DOC_MISMATCH = "CRIT:DOC_MISMATCH"
|
||||||
|
ZOMBIE = "CRIT:ZOMBIE"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def response(self):
|
||||||
|
return f"ACK {self.value}"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPeer(BaseModel):
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def pre_init(cls, data: Any) -> Any:
|
||||||
|
if not data["ip4"] and not data["ip6"]:
|
||||||
|
raise ValueError("Neither a IPv4 nor a IPv6 address was provided")
|
||||||
|
return data
|
||||||
|
|
||||||
|
@field_validator("is_self")
|
||||||
|
def local_self_validator(cls, v):
|
||||||
|
if v != True:
|
||||||
|
raise ValueError("LocalPeer does not have is_self flag")
|
||||||
|
return v
|
||||||
|
|
||||||
|
is_self: bool
|
||||||
|
name: constr(pattern=r"^[a-zA-Z0-9\-_\.]+$", min_length=3)
|
||||||
|
ip4: IPvAnyAddress | None = None
|
||||||
|
ip6: IPvAnyAddress | None = None
|
||||||
|
cli_bindings: list[IPvAnyAddress] = defaults.CLUSTER_CLI_BINDINGS
|
||||||
|
leader: str | None = None
|
||||||
|
role: Role = Role.FOLLOWER
|
||||||
|
swarm: str = ""
|
||||||
|
started: float = ntime_utc_now()
|
||||||
|
swarm_complete: bool = False
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def _bindings_as_str(self) -> str:
|
||||||
|
return [str(ip) for key in ("ip4", "ip6") if (ip := getattr(self, key))]
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def _all_bindings_as_str(self) -> str:
|
||||||
|
return [
|
||||||
|
str(ip) for key in ("ip4", "ip6") if (ip := getattr(self, key))
|
||||||
|
] + self.cli_bindings
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def cli_bindings_validator(self):
|
||||||
|
for ip in self.cli_bindings:
|
||||||
|
if ip == self.ip4 or ip == self.ip6:
|
||||||
|
raise ValueError("CLI bindings overlap local bindings")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class Streams(BaseModel):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
out: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None = None
|
||||||
|
_in: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemotePeer(BaseModel):
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def pre_init(cls, data: Any) -> Any:
|
||||||
|
if not data["ip4"] and not data["ip6"]:
|
||||||
|
raise ValueError("Neither a IPv4 nor a IPv6 address was provided")
|
||||||
|
return data
|
||||||
|
|
||||||
|
@field_validator("is_self")
|
||||||
|
def local_self_validator(cls, v):
|
||||||
|
if v:
|
||||||
|
raise ValueError("RemotePeer has is_self flag")
|
||||||
|
return v
|
||||||
|
|
||||||
|
is_self: bool = False
|
||||||
|
sending_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
swarm: str = ""
|
||||||
|
leader: str | None = None
|
||||||
|
started: float | None = None
|
||||||
|
name: constr(pattern=r"^[a-zA-Z0-9\-_\.]+$", min_length=3)
|
||||||
|
ip4: IPvAnyAddress | None = None
|
||||||
|
ip6: IPvAnyAddress | None = None
|
||||||
|
nat_ip4: IPvAnyAddress | None = None
|
||||||
|
streams: Streams = Streams()
|
||||||
|
port: int = 2102
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.streams._in = None
|
||||||
|
self.streams.out = None
|
||||||
|
self.leader = None
|
||||||
|
self.started = None
|
||||||
|
self.swarm = ""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _eval_ip(
|
||||||
|
self,
|
||||||
|
) -> tuple[IPvAnyAddress | None, tuple[ConnectionStatus, dict]]:
|
||||||
|
errors = dict()
|
||||||
|
peer_ips = [ip for ip in [self.ip4, self.ip6] if ip is not None]
|
||||||
|
for ip in peer_ips:
|
||||||
|
with closing(
|
||||||
|
socket.socket(
|
||||||
|
socket.AF_INET if ip.version == 4 else socket.AF_INET6,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
)
|
||||||
|
) as sock:
|
||||||
|
sock.settimeout(defaults.CLUSTER_PEERS_TIMEOUT)
|
||||||
|
connection_return = sock.connect_ex((str(ip), self.port))
|
||||||
|
if connection_return != 0:
|
||||||
|
errors[ip] = (
|
||||||
|
ConnectionStatus.SOCKET_REFUSED,
|
||||||
|
socket.errno.errorcode.get(connection_return),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if errors:
|
||||||
|
return ip, (ConnectionStatus.OK_WITH_PREVIOUS_ERRORS, errors)
|
||||||
|
return ip, (ConnectionStatus.OK, errors)
|
||||||
|
else:
|
||||||
|
return None, (ConnectionStatus.ALL_AVAILABLE_FAILED, errors)
|
||||||
|
|
||||||
|
async def connect(
|
||||||
|
self,
|
||||||
|
ip: IPvAnyAddress | None = None,
|
||||||
|
) -> tuple[
|
||||||
|
tuple[asyncio.StreamReader, asyncio.StreamWriter] | None,
|
||||||
|
tuple[ConnectionStatus, Any],
|
||||||
|
]:
|
||||||
|
if not self.streams.out:
|
||||||
|
if not ip:
|
||||||
|
ip, status = self._eval_ip()
|
||||||
|
if not ip:
|
||||||
|
return None, status
|
||||||
|
try:
|
||||||
|
self.streams.out = await asyncio.open_connection(
|
||||||
|
str(ip), self.port, ssl=get_ssl_context("client")
|
||||||
|
)
|
||||||
|
except ConnectionRefusedError as e:
|
||||||
|
return None, (ConnectionStatus.REFUSED, e)
|
||||||
|
|
||||||
|
return self.streams.out, (ConnectionStatus.CONNECTED, None)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def _all_ips_as_str(self) -> str:
|
||||||
|
return [str(ip) for key in ("ip4", "ip6") if (ip := getattr(self, key))]
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def _fully_established(self) -> str:
|
||||||
|
return (
|
||||||
|
True
|
||||||
|
if self.streams.out
|
||||||
|
and self.streams._in
|
||||||
|
and self.swarm
|
||||||
|
and self.started
|
||||||
|
and self.leader
|
||||||
|
else False
|
||||||
|
)
|
||||||
628
components/models/objects.py
Normal file
628
components/models/objects.py
Normal file
@ -0,0 +1,628 @@
|
|||||||
|
from components.utils import ensure_list, to_unique_sorted_str_list
|
||||||
|
from components.utils.datetimes import utc_now_as_str
|
||||||
|
from components.utils.cryptography import generate_rsa_dkim
|
||||||
|
from components.models import *
|
||||||
|
|
||||||
|
POLICIES = [
|
||||||
|
("-- None --", "disallow_all"),
|
||||||
|
("Send to external", "send_external"),
|
||||||
|
("Receive from external", "receive_external"),
|
||||||
|
("Send to internal", "send_internal"),
|
||||||
|
("Receive from internal", "receive_internal"),
|
||||||
|
]
|
||||||
|
|
||||||
|
POLICY_DESC = [p[1] for p in POLICIES]
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_email(v):
|
||||||
|
try:
|
||||||
|
name = validate_email(v).ascii_email
|
||||||
|
except:
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"name_invalid",
|
||||||
|
"The provided name is not a valid local part",
|
||||||
|
dict(name_invalid=v),
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_domain(v):
|
||||||
|
try:
|
||||||
|
name = validate_email(f"name@{v}").ascii_domain
|
||||||
|
except:
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"name_invalid",
|
||||||
|
"The provided name is not a valid domain name",
|
||||||
|
dict(name_invalid=v),
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_local_part(v):
|
||||||
|
try:
|
||||||
|
name = validate_email(f"{v}@example.org").ascii_local_part
|
||||||
|
except:
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"name_invalid",
|
||||||
|
"The provided name is not a valid local part",
|
||||||
|
dict(name_invalid=v),
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectDomain(BaseModel):
|
||||||
|
def model_post_init(self, __context):
|
||||||
|
if (
|
||||||
|
self.dkim_selector == self.arc_selector
|
||||||
|
and self.assigned_dkim_keypair != self.assigned_arc_keypair
|
||||||
|
):
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"selector_conflict",
|
||||||
|
"ARC and DKIM selectors cannot be the same while using different keys",
|
||||||
|
dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("bcc_inbound")
|
||||||
|
def bcc_inbound_validator(cls, v):
|
||||||
|
if v in [None, ""]:
|
||||||
|
return ""
|
||||||
|
return ascii_email(v)
|
||||||
|
|
||||||
|
@field_validator("bcc_outbound")
|
||||||
|
def bcc_outbound_validator(cls, v):
|
||||||
|
if v in [None, ""]:
|
||||||
|
return ""
|
||||||
|
return ascii_email(v)
|
||||||
|
|
||||||
|
@field_validator("domain")
|
||||||
|
def domain_validator(cls, v):
|
||||||
|
return ascii_domain(v)
|
||||||
|
|
||||||
|
display_name: str = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Display name",
|
||||||
|
"description": "The display name of the mailbox",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"mailbox-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
domain: str = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Domain name",
|
||||||
|
"description": "A unique domain name",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"domain-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
bcc_inbound: str = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "BCC inbound",
|
||||||
|
"description": "BCC destination for incoming messages",
|
||||||
|
"type": "email",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"bcc-inbound-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
bcc_outbound: str = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "BCC outbound",
|
||||||
|
"description": "BCC destination for outbound messages",
|
||||||
|
"type": "email",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"bcc-outbound-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
n_mailboxes: conint(ge=0) | Literal[""] = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Max. mailboxes",
|
||||||
|
"description": "Limit domain to n mailboxes",
|
||||||
|
"type": "number",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"n-mailboxes-outbound-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ratelimit: conint(ge=0) | Literal[""] = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Ratelimit",
|
||||||
|
"description": "Amount of elements to allow in a given time unit (see below)",
|
||||||
|
"type": "number",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"ratelimit-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ratelimit_unit: Literal["day", "hour", "minute"] = Field(
|
||||||
|
default="hour",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Ratelimit unit",
|
||||||
|
"description": "Policy override options.",
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
{"name": "Day", "value": "day"},
|
||||||
|
{"name": "Hour", "value": "hour"},
|
||||||
|
{"name": "Minute", "value": "minute"},
|
||||||
|
],
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"ratelimit-unit-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dkim: Annotated[
|
||||||
|
Literal[True, False],
|
||||||
|
BeforeValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
AfterValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
] = Field(
|
||||||
|
default=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "DKIM",
|
||||||
|
"description": "Enable DKIM signatures",
|
||||||
|
"type": "radio",
|
||||||
|
"input_extra": 'autocomplete="off"',
|
||||||
|
"form_id": f"dkim-signatures-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
arc: Annotated[
|
||||||
|
Literal[True, False],
|
||||||
|
BeforeValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
AfterValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
] = Field(
|
||||||
|
default=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "ARC",
|
||||||
|
"description": "Enable ARC signatures",
|
||||||
|
"type": "radio",
|
||||||
|
"input_extra": 'autocomplete="off"',
|
||||||
|
"form_id": f"arc-signatures-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dkim_selector: constr(strip_whitespace=True, min_length=1) = Field(
|
||||||
|
default="mail",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "DKIM Selector",
|
||||||
|
"description": "Selector name",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"dkim-selector-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
arc_selector: constr(strip_whitespace=True, min_length=1) = Field(
|
||||||
|
default="mail",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "ARC Selector",
|
||||||
|
"description": "Selector name",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"arc-selector-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_dkim_keypair: BaseModel | str = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "DKIM key pair",
|
||||||
|
"description": "Assign a key pair for DKIM signatures.",
|
||||||
|
"type": "keypair",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-dkim-keypair-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_arc_keypair: BaseModel | str = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "ARC key pair",
|
||||||
|
"description": "Assign a key pair for ARC signatures.",
|
||||||
|
"type": "keypair",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-arc-keypair-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
policies: Annotated[
|
||||||
|
Literal[*POLICY_DESC] | list[Literal[*POLICY_DESC]],
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
default=["disallow_all"],
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Policies",
|
||||||
|
"description": "Policies for this domain.",
|
||||||
|
"type": "select:multi",
|
||||||
|
"options": [{"name": p[0], "value": p[1]} for p in POLICIES],
|
||||||
|
"input_extra": '_="on change if event.target.value is \'disallow_all\' set my selectedIndex to 0 end" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"policies-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
policy_weight: str = Field(
|
||||||
|
default="domain_mailbox",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Policy weight",
|
||||||
|
"description": "Policy override options.",
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
{"name": "Domain > Mailbox", "value": "domain_mailbox"},
|
||||||
|
{"name": "Mailbox > Domain", "value": "mailbox_domain"},
|
||||||
|
],
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"policy-weight-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_users: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Assigned users",
|
||||||
|
"description": "Assign this object to users.",
|
||||||
|
"type": "users:multi",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-users-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAddress(BaseModel):
|
||||||
|
local_part: constr(strip_whitespace=True, min_length=1) = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Local part",
|
||||||
|
"description": "A local part as in <local_part>@example.org; must be unique in combination with its assigned domain",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"local-part-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_domain: BaseModel | str = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Assigned domain",
|
||||||
|
"description": "Assign a domain for this address.",
|
||||||
|
"type": "domain",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"domain-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_emailusers: Annotated[
|
||||||
|
str | BaseModel | None | list[BaseModel | str | None],
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
default=[],
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Assigned email users",
|
||||||
|
"description": "Assign this mailbox to email users.",
|
||||||
|
"type": "emailusers:multi",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-emailusers-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name: str = Field(
|
||||||
|
default="%DOMAIN_DISPLAY_NAME%",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Display name",
|
||||||
|
"description": "You can use %DOMAIN_DISPLAY_NAME% as variable",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"mailbox-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
policies: Annotated[
|
||||||
|
Literal[*POLICY_DESC] | list[Literal[*POLICY_DESC]],
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
default=["disallow_all"],
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Policies",
|
||||||
|
"description": "Policies for this address.",
|
||||||
|
"type": "select:multi",
|
||||||
|
"options": [{"name": p[0], "value": p[1]} for p in POLICIES],
|
||||||
|
"input_extra": '_="on change if event.target.value is \'disallow_all\' set my selectedIndex to 0 end" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"policies-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_users: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Assigned users",
|
||||||
|
"description": "Assign this object to users.",
|
||||||
|
"type": "users:multi",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-users-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectUser(BaseModel):
|
||||||
|
username: constr(strip_whitespace=True, min_length=1) = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Username",
|
||||||
|
"description": "A unique username",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"login-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name: str = Field(
|
||||||
|
default="%MAILBOX_DISPLAY_NAME%",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Display name",
|
||||||
|
"description": "You can use %MAILBOX_DISPLAY_NAME% and %DOMAIN_DISPLAY_NAME% variables",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"mailbox-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
policies: Annotated[
|
||||||
|
Literal[*POLICY_DESC] | list[Literal[*POLICY_DESC]],
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
default=["disallow_all"],
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Policies",
|
||||||
|
"description": "Policies for this address.",
|
||||||
|
"type": "select:multi",
|
||||||
|
"options": [{"name": p[0], "value": p[1]} for p in POLICIES],
|
||||||
|
"input_extra": '_="on change if event.target.value is \'disallow_all\' set my selectedIndex to 0 end" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"policies-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_users: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Assigned users",
|
||||||
|
"description": "Assign this object to users.",
|
||||||
|
"type": "users:multi",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-users-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectKeyPair(BaseModel):
|
||||||
|
private_key_pem: str
|
||||||
|
public_key_base64: str
|
||||||
|
key_size: int
|
||||||
|
key_name: constr(strip_whitespace=True, min_length=1) = Field(
|
||||||
|
default="KeyPair",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Name",
|
||||||
|
"description": "A human readable name",
|
||||||
|
"type": "text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"keyname-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assigned_users: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Assigned users",
|
||||||
|
"description": "Assign this object to users.",
|
||||||
|
"type": "users:multi",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"assigned-users-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def dns_formatted(self) -> str:
|
||||||
|
return (
|
||||||
|
"v=DKIM1; p=" + self.public_key_base64 if self.public_key_base64 else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectBase(BaseModel):
|
||||||
|
id: Annotated[str, AfterValidator(lambda v: str(UUID(v)))]
|
||||||
|
created: str
|
||||||
|
updated: str
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectBaseDomain(ObjectBase):
|
||||||
|
details: ObjectDomain
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.details.domain
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectBaseUser(ObjectBase):
|
||||||
|
details: ObjectUser
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.details.username
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectBaseAddress(ObjectBase):
|
||||||
|
details: ObjectAddress
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.details.local_part
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectBaseKeyPair(ObjectBase):
|
||||||
|
details: ObjectKeyPair
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.details.key_name
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAdd(BaseModel):
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return str(uuid4())
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def created(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAddDomain(ObjectAdd):
|
||||||
|
details: ObjectDomain
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAddAddress(ObjectAdd):
|
||||||
|
details: ObjectAddress
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAddUser(ObjectAdd):
|
||||||
|
details: ObjectUser
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAddKeyPair(ObjectAdd):
|
||||||
|
def model_post_init(self, __context):
|
||||||
|
print(self)
|
||||||
|
print(__context)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def pre_init(cls, data: Any) -> Any:
|
||||||
|
if not all(
|
||||||
|
data["details"].get(k)
|
||||||
|
for k in ObjectKeyPair.__fields__
|
||||||
|
if k != "assigned_users"
|
||||||
|
):
|
||||||
|
data["details"] = cls.generate_rsa(
|
||||||
|
2048,
|
||||||
|
data["details"].get("assigned_users", []),
|
||||||
|
data["details"].get("key_name", "KeyPair"),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_rsa(
|
||||||
|
cls, key_size: int = 2048, assigned_users: list = [], key_name: str = "KeyPair"
|
||||||
|
) -> "ObjectKeyPair":
|
||||||
|
priv, pub = generate_rsa_dkim(key_size)
|
||||||
|
return ObjectKeyPair(
|
||||||
|
private_key_pem=priv,
|
||||||
|
public_key_base64=pub,
|
||||||
|
key_size=key_size,
|
||||||
|
assigned_users=assigned_users,
|
||||||
|
key_name=key_name,
|
||||||
|
).dict()
|
||||||
|
|
||||||
|
details: ObjectKeyPair
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectPatch(BaseModel):
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectPatchDomain(ObjectPatch):
|
||||||
|
details: ObjectDomain
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectPatchUser(ObjectPatch):
|
||||||
|
details: ObjectUser
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectPatchAddress(ObjectPatch):
|
||||||
|
details: ObjectAddress
|
||||||
|
|
||||||
|
|
||||||
|
class _KeyPairHelper(ObjectPatch):
|
||||||
|
key_name: constr(strip_whitespace=True, min_length=1) = Field(
|
||||||
|
default="KeyPair",
|
||||||
|
)
|
||||||
|
assigned_users: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectPatchKeyPair(ObjectPatch):
|
||||||
|
details: _KeyPairHelper
|
||||||
|
|
||||||
|
|
||||||
|
model_classes = {
|
||||||
|
"types": ["domains", "addresses", "emailusers", "keypairs"],
|
||||||
|
"forms": {
|
||||||
|
"domains": ObjectDomain,
|
||||||
|
"addresses": ObjectAddress,
|
||||||
|
"emailusers": ObjectUser,
|
||||||
|
"keypairs": ObjectKeyPair,
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"domains": ObjectPatchDomain,
|
||||||
|
"addresses": ObjectPatchAddress,
|
||||||
|
"emailusers": ObjectPatchUser,
|
||||||
|
"keypairs": ObjectPatchKeyPair,
|
||||||
|
},
|
||||||
|
"add": {
|
||||||
|
"domains": ObjectAddDomain,
|
||||||
|
"addresses": ObjectAddAddress,
|
||||||
|
"emailusers": ObjectAddUser,
|
||||||
|
"keypairs": ObjectAddKeyPair,
|
||||||
|
},
|
||||||
|
"base": {
|
||||||
|
"domains": ObjectBaseDomain,
|
||||||
|
"addresses": ObjectBaseAddress,
|
||||||
|
"emailusers": ObjectBaseUser,
|
||||||
|
"keypairs": ObjectBaseKeyPair,
|
||||||
|
},
|
||||||
|
"unique_fields": {
|
||||||
|
"domains": ["domain"],
|
||||||
|
"addresses": ["local_part", "assigned_domain"],
|
||||||
|
"emailusers": ["username"],
|
||||||
|
"keypairs": ["key_name"],
|
||||||
|
},
|
||||||
|
"system_fields": {
|
||||||
|
"domains": ["assigned_users", "n_mailboxes"],
|
||||||
|
"addresses": ["assigned_users"],
|
||||||
|
"emailusers": ["assigned_users"],
|
||||||
|
"keypairs": ["assigned_users"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectIdList(BaseModel):
|
||||||
|
object_id: Annotated[
|
||||||
|
UUID | list[UUID],
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
]
|
||||||
91
components/models/system.py
Normal file
91
components/models/system.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
from config import defaults
|
||||||
|
from components.utils import ensure_list
|
||||||
|
from components.utils.datetimes import utc_now_as_str
|
||||||
|
from components.models import *
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettings(BaseModel):
|
||||||
|
ACCEPT_LANGUAGES: Annotated[
|
||||||
|
list[Literal["en", "de"]],
|
||||||
|
BeforeValidator(lambda x: ensure_list(x)),
|
||||||
|
] = Field(
|
||||||
|
min_length=1,
|
||||||
|
default=defaults.ACCEPT_LANGUAGES,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Accepted languages",
|
||||||
|
"description": "Accepted languages by the clients browser.",
|
||||||
|
"type": "list:text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"accept-languages-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_REQUEST_TIMEOUT: int = Field(
|
||||||
|
default=defaults.AUTH_REQUEST_TIMEOUT,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Proxy timeout",
|
||||||
|
"description": "Proxy authentication timeout",
|
||||||
|
"type": "number",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"proxy-auth-timeout-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TABLE_PAGE_SIZE: int = Field(
|
||||||
|
default=defaults.TABLE_PAGE_SIZE,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Table page size",
|
||||||
|
"description": "Default table page size",
|
||||||
|
"type": "number",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"table-page-size-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG_FILE_RETENTION: int = Field(
|
||||||
|
default=defaults.LOG_FILE_RETENTION,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Log file retention",
|
||||||
|
"description": "Number of log files to keep after each rotation",
|
||||||
|
"type": "number",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"log-file-retention-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG_FILE_ROTATION: int = Field(
|
||||||
|
default=defaults.LOG_FILE_ROTATION,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Max size in bytes",
|
||||||
|
"description": "Log files will rotate after given bytes",
|
||||||
|
"type": "number",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"log-file-rotation-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATES_AUTO_RELOAD: Annotated[
|
||||||
|
Literal[True, False],
|
||||||
|
BeforeValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
AfterValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
] = Field(
|
||||||
|
default=defaults.TEMPLATES_AUTO_RELOAD,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Reload templates",
|
||||||
|
"description": "Automatically reload templates on change",
|
||||||
|
"type": "radio",
|
||||||
|
"input_extra": 'autocomplete="off"',
|
||||||
|
"form_id": f"templats-auto-reload-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsBase(BaseModel):
|
||||||
|
details: SystemSettings = SystemSettings()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSystemSettings(SystemSettingsBase):
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
49
components/models/tables.py
Normal file
49
components/models/tables.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from config import defaults
|
||||||
|
from components.utils import ensure_list, to_unique_sorted_str_list
|
||||||
|
from components.models import *
|
||||||
|
|
||||||
|
|
||||||
|
class TableSearch(BaseModel):
|
||||||
|
q: Annotated[Any, AfterValidator(lambda x: str(x))] = ""
|
||||||
|
page: Annotated[Any, AfterValidator(lambda x: int(x) if x else 1)] = 1
|
||||||
|
page_size: Annotated[
|
||||||
|
Any,
|
||||||
|
AfterValidator(lambda x: int(x) if x else defaults.TABLE_PAGE_SIZE),
|
||||||
|
] = defaults.TABLE_PAGE_SIZE
|
||||||
|
sorting: tuple = ("created", True)
|
||||||
|
filters: Annotated[
|
||||||
|
Any,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
@field_validator("filters", mode="after")
|
||||||
|
def filters_formatter(cls, v):
|
||||||
|
filters = dict()
|
||||||
|
for f in v:
|
||||||
|
key_name, key_value = f.split(":")
|
||||||
|
if key_name == "assigned_users":
|
||||||
|
continue
|
||||||
|
if key_name not in filters.keys():
|
||||||
|
filters[key_name] = key_value
|
||||||
|
else:
|
||||||
|
if isinstance(filters[key_name], list):
|
||||||
|
filters[key_name].append(key_value)
|
||||||
|
else:
|
||||||
|
filters[key_name] = [filters[key_name], key_value]
|
||||||
|
|
||||||
|
return filters
|
||||||
|
|
||||||
|
@field_validator("sorting", mode="before")
|
||||||
|
def split_sorting(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
match v.split(":"):
|
||||||
|
case [
|
||||||
|
sort_attr,
|
||||||
|
direction,
|
||||||
|
]:
|
||||||
|
sort_reverse = True if direction == "desc" else False
|
||||||
|
case _:
|
||||||
|
sort_reverse = True
|
||||||
|
sort_attr = "created"
|
||||||
|
|
||||||
|
return (sort_attr, sort_reverse)
|
||||||
264
components/models/users.py
Normal file
264
components/models/users.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import random
|
||||||
|
import json
|
||||||
|
from config.defaults import USER_ACLS, ACCESS_TOKEN_FORMAT, ACCEPT_LANGUAGES
|
||||||
|
from components.utils import ensure_list, to_unique_sorted_str_list
|
||||||
|
from components.utils.datetimes import ntime_utc_now, utc_now_as_str
|
||||||
|
from webauthn.helpers.structs import AuthenticatorTransport
|
||||||
|
from components.models import *
|
||||||
|
|
||||||
|
|
||||||
|
class TokenConfirmation(BaseModel):
|
||||||
|
confirmation_code: Annotated[int, AfterValidator(lambda i: "%06d" % i)]
|
||||||
|
token: str = constr(strip_whitespace=True, min_length=14, max_length=14)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthToken(BaseModel):
|
||||||
|
login: str = constr(strip_whitespace=True, min_length=1)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def token(self) -> str:
|
||||||
|
return "%04d-%04d-%04d" % (
|
||||||
|
random.randint(0, 9999),
|
||||||
|
random.randint(0, 9999),
|
||||||
|
random.randint(0, 9999),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Credential(BaseModel):
|
||||||
|
id: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
|
||||||
|
public_key: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
|
||||||
|
friendly_name: constr(strip_whitespace=True, min_length=1)
|
||||||
|
last_login: str
|
||||||
|
sign_count: int
|
||||||
|
transports: list[AuthenticatorTransport] | None = []
|
||||||
|
active: bool
|
||||||
|
updated: str
|
||||||
|
created: str
|
||||||
|
|
||||||
|
@field_serializer("id", "public_key")
|
||||||
|
def serialize_bytes_to_hex(self, v: bytes, _info):
|
||||||
|
return v.hex() if isinstance(v, bytes) else v
|
||||||
|
|
||||||
|
|
||||||
|
class AddCredential(BaseModel):
|
||||||
|
id: Annotated[bytes, AfterValidator(lambda x: x.hex())] | str
|
||||||
|
public_key: Annotated[bytes, AfterValidator(lambda x: x.hex())] | str
|
||||||
|
sign_count: int
|
||||||
|
friendly_name: str = "New passkey"
|
||||||
|
transports: list[AuthenticatorTransport] | None = []
|
||||||
|
active: bool = True
|
||||||
|
last_login: str = ""
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def created(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(BaseModel):
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
@field_validator("email", mode="before")
|
||||||
|
def email_validator(cls, v):
|
||||||
|
if v in [None, ""]:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
email = validate_email(v, check_deliverability=False).ascii_email
|
||||||
|
except:
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"email_invalid",
|
||||||
|
"The provided email address is invalid",
|
||||||
|
dict(provided_email=v),
|
||||||
|
)
|
||||||
|
return email
|
||||||
|
|
||||||
|
@field_validator("access_tokens", mode="after")
|
||||||
|
def access_tokens_validator(cls, v):
|
||||||
|
for s in v:
|
||||||
|
try:
|
||||||
|
TypeAdapter(ACCESS_TOKEN_FORMAT).validate_python(s)
|
||||||
|
except ValidationError as e:
|
||||||
|
s_priv = s[:3] + (s[3:] and "***")
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"access_tokens",
|
||||||
|
f"The provided token {s_priv} is invalid",
|
||||||
|
dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("tresor", mode="after")
|
||||||
|
def parse_tresor(cls, v):
|
||||||
|
try:
|
||||||
|
if v:
|
||||||
|
tresor = json.loads(v)
|
||||||
|
assert all(
|
||||||
|
k in tresor.keys()
|
||||||
|
for k in ["public_key_pem", "wrapped_private_key", "iv", "salt"]
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
except:
|
||||||
|
raise PydanticCustomError(
|
||||||
|
"tresor",
|
||||||
|
f"The provided tresor data is invalid",
|
||||||
|
dict(),
|
||||||
|
)
|
||||||
|
|
||||||
|
tresor: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Personal tresor",
|
||||||
|
"type": "tresor",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"tresor-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
email: str = Field(
|
||||||
|
default="",
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Email address",
|
||||||
|
"description": "Your email address is optional",
|
||||||
|
"type": "email",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"email-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
access_tokens: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = Field(
|
||||||
|
default=[],
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Access tokens",
|
||||||
|
"description": "Tokens to access the API. Save profile after removing a token.",
|
||||||
|
"type": "list:text",
|
||||||
|
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||||
|
"form_id": f"access-tokens-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
permit_auth_requests: Annotated[
|
||||||
|
Literal[True, False],
|
||||||
|
BeforeValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
AfterValidator(lambda x: True if str(x).lower() == "true" else False),
|
||||||
|
] = Field(
|
||||||
|
default=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"title": "Authentication requests",
|
||||||
|
"description": "Allow other devices to issue authentication requests to active sessions via pop-up",
|
||||||
|
"type": "radio",
|
||||||
|
"input_extra": 'autocomplete="off"',
|
||||||
|
"form_id": f"proxy-login-{str(uuid4())}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
updated: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
id: Annotated[str, AfterValidator(lambda v: str(UUID(v)))]
|
||||||
|
login: constr(strip_whitespace=True, min_length=1)
|
||||||
|
credentials: dict[str, Credential] | Annotated[
|
||||||
|
str | list[str],
|
||||||
|
AfterValidator(lambda v: ensure_list(v)),
|
||||||
|
] = {}
|
||||||
|
acl: Annotated[
|
||||||
|
Literal[*USER_ACLS] | list[Literal[*USER_ACLS]],
|
||||||
|
AfterValidator(lambda v: ensure_list(v)),
|
||||||
|
]
|
||||||
|
groups: Annotated[
|
||||||
|
constr(strip_whitespace=True, min_length=1)
|
||||||
|
| list[constr(strip_whitespace=True, min_length=1)],
|
||||||
|
AfterValidator(lambda v: ensure_list(v)),
|
||||||
|
] = []
|
||||||
|
profile: UserProfile
|
||||||
|
created: str
|
||||||
|
updated: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroups(BaseModel):
|
||||||
|
name: constr(strip_whitespace=True, min_length=1)
|
||||||
|
new_name: constr(strip_whitespace=True, min_length=1)
|
||||||
|
members: Annotated[
|
||||||
|
str | list,
|
||||||
|
AfterValidator(lambda x: to_unique_sorted_str_list(ensure_list(x))),
|
||||||
|
] = []
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdd(BaseModel):
|
||||||
|
login: str = constr(strip_whitespace=True, min_length=1)
|
||||||
|
credentials: list[str] = []
|
||||||
|
acl: list[Literal[*USER_ACLS]] = ["user"]
|
||||||
|
profile: UserProfile = UserProfile.parse_obj({})
|
||||||
|
groups: list[constr(strip_whitespace=True, min_length=1)] = []
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return str(uuid4())
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def created(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class UserPatch(BaseModel):
|
||||||
|
login: str | None = None
|
||||||
|
acl: str | list = []
|
||||||
|
credentials: str | list = []
|
||||||
|
groups: str | list | None = None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfilePatch(BaseModel):
|
||||||
|
email: str | None = None
|
||||||
|
tresor: str | None = None
|
||||||
|
access_tokens: str | list = []
|
||||||
|
permit_auth_requests: bool | None = None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialPatch(BaseModel):
|
||||||
|
friendly_name: str | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
last_login: str | None = None
|
||||||
|
sign_count: int | None = None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def updated(self) -> str:
|
||||||
|
return utc_now_as_str()
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(BaseModel):
|
||||||
|
id: str
|
||||||
|
login: str
|
||||||
|
acl: list | str
|
||||||
|
cred_id: str | None = None
|
||||||
|
lang: Literal[*ACCEPT_LANGUAGES] = "en"
|
||||||
|
profile: dict | UserProfile | None = {}
|
||||||
|
login_ts: float = Field(default_factory=ntime_utc_now)
|
||||||
401
components/objects.py
Normal file
401
components/objects.py
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
from components.models.objects import (
|
||||||
|
ObjectIdList,
|
||||||
|
model_classes,
|
||||||
|
validate_call,
|
||||||
|
UUID,
|
||||||
|
Literal,
|
||||||
|
)
|
||||||
|
from components.web.utils.quart import current_app, session
|
||||||
|
from components.utils import ensure_list, merge_models
|
||||||
|
from components.cache import buster
|
||||||
|
from components.database import *
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def get(
|
||||||
|
object_type: Literal[*model_classes["types"]],
|
||||||
|
object_id: UUID | list[UUID],
|
||||||
|
permission_validation=True,
|
||||||
|
):
|
||||||
|
get_objects = ObjectIdList(object_id=object_id).object_id
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
if current_app and session.get("id"):
|
||||||
|
user_id = session["id"]
|
||||||
|
else:
|
||||||
|
user_id = "anonymous"
|
||||||
|
|
||||||
|
if not user_id in IN_MEMORY_DB["OBJECTS_CACHE"]:
|
||||||
|
IN_MEMORY_DB["OBJECTS_CACHE"][user_id] = dict()
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
found_objects = db.table(object_type).search(Query().id.one_of(get_objects))
|
||||||
|
|
||||||
|
object_data = []
|
||||||
|
|
||||||
|
for o in found_objects:
|
||||||
|
o_parsed = model_classes["base"][object_type].model_validate(o)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not "system" in session["acl"]
|
||||||
|
and permission_validation == True
|
||||||
|
and user_id not in o_parsed.details.assigned_users
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for k, v in o_parsed.details.model_dump(mode="json").items():
|
||||||
|
if k == "assigned_domain":
|
||||||
|
if not v in IN_MEMORY_DB["OBJECTS_CACHE"][user_id]:
|
||||||
|
IN_MEMORY_DB["OBJECTS_CACHE"][user_id][v] = await get(
|
||||||
|
object_type="domains",
|
||||||
|
object_id=v,
|
||||||
|
permission_validation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
o_parsed.details.assigned_domain = IN_MEMORY_DB["OBJECTS_CACHE"][
|
||||||
|
user_id
|
||||||
|
][v]
|
||||||
|
elif k in ["assigned_arc_keypair", "assigned_dkim_keypair"] and v:
|
||||||
|
if not v in IN_MEMORY_DB["OBJECTS_CACHE"][user_id]:
|
||||||
|
IN_MEMORY_DB["OBJECTS_CACHE"][user_id][v] = await get(
|
||||||
|
object_type="keypairs",
|
||||||
|
object_id=v,
|
||||||
|
permission_validation=False,
|
||||||
|
)
|
||||||
|
setattr(o_parsed.details, k, IN_MEMORY_DB["OBJECTS_CACHE"][user_id][v])
|
||||||
|
elif k == "assigned_emailusers" and v:
|
||||||
|
o_parsed.details.assigned_emailusers = []
|
||||||
|
for u in ensure_list(v):
|
||||||
|
if not u in IN_MEMORY_DB["OBJECTS_CACHE"][user_id]:
|
||||||
|
IN_MEMORY_DB["OBJECTS_CACHE"][user_id][u] = await get(
|
||||||
|
object_type="emailusers",
|
||||||
|
object_id=u,
|
||||||
|
permission_validation=False,
|
||||||
|
)
|
||||||
|
o_parsed.details.assigned_emailusers.append(
|
||||||
|
IN_MEMORY_DB["OBJECTS_CACHE"][user_id][u]
|
||||||
|
)
|
||||||
|
|
||||||
|
object_data.append(o_parsed)
|
||||||
|
|
||||||
|
if len(object_data) == 1:
|
||||||
|
return object_data.pop()
|
||||||
|
|
||||||
|
return object_data if object_data else None
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
object_type: Literal[*model_classes["types"]],
|
||||||
|
object_id: UUID | list[UUID],
|
||||||
|
):
|
||||||
|
delete_objects = [o for o in ensure_list(await get(object_type, object_id))]
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
if object_type == "domains":
|
||||||
|
for o in delete_objects:
|
||||||
|
addresses = await search(object_type="addresses", fully_resolve=False)
|
||||||
|
if o.id in [address.details.assigned_domain for address in addresses]:
|
||||||
|
raise ValueError("name", f"Domain {o.name} is not empty")
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
buster([o.id for o in delete_objects])
|
||||||
|
return db.table(object_type).remove(
|
||||||
|
Query().id.one_of([o.id for o in delete_objects])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def patch(
|
||||||
|
object_type: Literal[*model_classes["types"]],
|
||||||
|
object_id: UUID | list[UUID],
|
||||||
|
data: dict,
|
||||||
|
):
|
||||||
|
assert current_app and session.get("id")
|
||||||
|
|
||||||
|
to_patch_objects = [o for o in ensure_list(await get(object_type, object_id))]
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
for to_patch in to_patch_objects:
|
||||||
|
if not "system" in session["acl"]:
|
||||||
|
if not "details" in data:
|
||||||
|
data["details"] = dict()
|
||||||
|
for f in model_classes["system_fields"][object_type]:
|
||||||
|
data["details"][f] = getattr(to_patch.details, f)
|
||||||
|
|
||||||
|
patch_data = model_classes["patch"][object_type].model_validate(data)
|
||||||
|
patched_object = merge_models(
|
||||||
|
to_patch, patch_data
|
||||||
|
) # returns updated to_patch model
|
||||||
|
|
||||||
|
conflicts = await search(
|
||||||
|
object_type=object_type,
|
||||||
|
match_all={
|
||||||
|
f: getattr(patched_object.details, f)
|
||||||
|
for f in model_classes["unique_fields"][object_type]
|
||||||
|
},
|
||||||
|
fully_resolve=False,
|
||||||
|
)
|
||||||
|
if [o.id for o in conflicts if o.id != patched_object.id]:
|
||||||
|
raise ValueError(
|
||||||
|
f"details.{model_classes['unique_fields'][object_type][0]}",
|
||||||
|
"The provided object exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
if object_type == "domains":
|
||||||
|
if "system" in session["acl"]:
|
||||||
|
addresses_in_domain = await search(
|
||||||
|
object_type="addresses",
|
||||||
|
match_all={"assigned_domain": patched_object.id},
|
||||||
|
fully_resolve=False,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
patched_object.details.n_mailboxes
|
||||||
|
and len(addresses_in_domain) > patched_object.details.n_mailboxes
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"details.n_mailboxes",
|
||||||
|
f"Cannot reduce allowed mailboxes below {len(addresses_in_domain)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
for attr in ["assigned_dkim_keypair", "assigned_arc_keypair"]:
|
||||||
|
# keypairs default to "", verify
|
||||||
|
if attr not in data.get("details", {}):
|
||||||
|
continue
|
||||||
|
|
||||||
|
patched_obj_keypair = getattr(patched_object.details, attr)
|
||||||
|
patched_obj_keypair_id = (
|
||||||
|
patched_obj_keypair.id
|
||||||
|
if hasattr(patched_obj_keypair, "id")
|
||||||
|
else patched_obj_keypair
|
||||||
|
)
|
||||||
|
|
||||||
|
to_patch_obj_keypair = getattr(to_patch.details, attr)
|
||||||
|
to_patch_obj_keypair_id = (
|
||||||
|
to_patch_obj_keypair.id
|
||||||
|
if hasattr(to_patch_obj_keypair, "id")
|
||||||
|
else to_patch_obj_keypair
|
||||||
|
)
|
||||||
|
|
||||||
|
if patched_obj_keypair_id != to_patch_obj_keypair_id:
|
||||||
|
if not "system" in session["acl"]:
|
||||||
|
if not await get("keypairs", to_patch_obj_keypair_id):
|
||||||
|
raise ValueError(
|
||||||
|
f"details.{attr}",
|
||||||
|
f"Cannot unassign a non-permitted keypair",
|
||||||
|
)
|
||||||
|
if not await get("keypairs", patched_obj_keypair_id):
|
||||||
|
raise ValueError(
|
||||||
|
f"details.{attr}",
|
||||||
|
f"Cannot assign non-permitted keypair",
|
||||||
|
)
|
||||||
|
if object_type == "addresses":
|
||||||
|
if not "system" in session["acl"]:
|
||||||
|
if (
|
||||||
|
patched_object.details.assigned_domain
|
||||||
|
!= to_patch.details.assigned_domain.id
|
||||||
|
): # only when assigned_domain changed
|
||||||
|
if (
|
||||||
|
not len(
|
||||||
|
await get(
|
||||||
|
"domains",
|
||||||
|
[
|
||||||
|
patched_object.details.assigned_domain,
|
||||||
|
to_patch.details.assigned_domain.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== 2
|
||||||
|
):
|
||||||
|
# disallow a change to a permitted domain if the current domain is not permitted
|
||||||
|
raise ValueError(
|
||||||
|
"details.assigned_domain",
|
||||||
|
f"Cannot assign selected domain for object {to_patch.details.local_part}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if set(patched_object.details.assigned_emailusers) != set(
|
||||||
|
[u.id for u in to_patch.details.assigned_emailusers]
|
||||||
|
): # only when assigned_emailusers changed
|
||||||
|
non_permitted_users = set()
|
||||||
|
|
||||||
|
for emailuser in [
|
||||||
|
*patched_object.details.assigned_emailusers,
|
||||||
|
*[u.id for u in to_patch.details.assigned_emailusers],
|
||||||
|
]:
|
||||||
|
_ = await get(
|
||||||
|
object_type="emailusers",
|
||||||
|
object_id=emailuser,
|
||||||
|
permission_validation=False,
|
||||||
|
)
|
||||||
|
if session["id"] not in _.details.assigned_users:
|
||||||
|
non_permitted_users.add(_.name if _ else "<unknown>")
|
||||||
|
|
||||||
|
if non_permitted_users:
|
||||||
|
# disallow a change to a permitted domain if the current domain is not permitted
|
||||||
|
raise ValueError(
|
||||||
|
"details.assigned_emailusers",
|
||||||
|
f"You are not allow to change email user assignments for {', '.join(non_permitted_users)} of address {to_patch.details.local_part}",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
patched_object.details.assigned_domain
|
||||||
|
!= to_patch.details.assigned_domain.id
|
||||||
|
):
|
||||||
|
addresses_in_new_domain_now = await search(
|
||||||
|
object_type="addresses",
|
||||||
|
match_all={
|
||||||
|
"assigned_domain": patched_object.details.assigned_domain
|
||||||
|
},
|
||||||
|
fully_resolve=False,
|
||||||
|
)
|
||||||
|
new_domain_data = await get(
|
||||||
|
object_type="domains",
|
||||||
|
object_id=patched_object.details.assigned_domain,
|
||||||
|
permission_validation=True,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
new_domain_data.details.n_mailboxes
|
||||||
|
and new_domain_data.details.n_mailboxes
|
||||||
|
< (len(addresses_in_new_domain_now) + 1)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"details.assigned_domain",
|
||||||
|
"The domain's mailbox limit is reached",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
db.table(object_type).update(
|
||||||
|
patched_object.model_dump(
|
||||||
|
mode="json", exclude_none=True, exclude={"name", "id", "created"}
|
||||||
|
),
|
||||||
|
Query().id == to_patch.id,
|
||||||
|
)
|
||||||
|
buster([o.id for o in to_patch_objects])
|
||||||
|
|
||||||
|
return [o.id for o in to_patch_objects]
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
object_type: Literal[*model_classes["types"]],
|
||||||
|
data: dict,
|
||||||
|
):
|
||||||
|
assert current_app and session.get("id")
|
||||||
|
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
if not "details" in data:
|
||||||
|
data["details"] = dict()
|
||||||
|
|
||||||
|
data["details"]["assigned_users"] = session["id"]
|
||||||
|
|
||||||
|
create_object = model_classes["add"][object_type].model_validate(data)
|
||||||
|
|
||||||
|
conflicts = await search(
|
||||||
|
object_type=object_type,
|
||||||
|
match_all={
|
||||||
|
f: getattr(create_object.details, f)
|
||||||
|
for f in model_classes["unique_fields"][object_type]
|
||||||
|
},
|
||||||
|
fully_resolve=False,
|
||||||
|
)
|
||||||
|
if [o.id for o in conflicts]:
|
||||||
|
raise ValueError(
|
||||||
|
f"details.{model_classes['unique_fields'][object_type][0]}",
|
||||||
|
"The provided object exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
if object_type == "addresses":
|
||||||
|
if not "system" in session["acl"]:
|
||||||
|
if not await get("domains", create_object.details.assigned_domain):
|
||||||
|
raise ValueError("name", "The provided domain is unavailable")
|
||||||
|
|
||||||
|
addresses_in_domain = await search(
|
||||||
|
object_type="addresses",
|
||||||
|
match_all={"assigned_domain": create_object.details.assigned_domain},
|
||||||
|
fully_resolve=False,
|
||||||
|
)
|
||||||
|
domain_data = await get(
|
||||||
|
object_type="domains",
|
||||||
|
object_id=create_object.details.assigned_domain,
|
||||||
|
permission_validation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
domain_assigned_users = set(domain_data.details.assigned_users)
|
||||||
|
domain_assigned_users.add(session["id"])
|
||||||
|
create_object.details.assigned_users = list(domain_assigned_users)
|
||||||
|
|
||||||
|
if domain_data.details.n_mailboxes and domain_data.details.n_mailboxes < (
|
||||||
|
len(addresses_in_domain) + 1
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"details.assigned_domain",
|
||||||
|
"The domain's mailbox limit is reached",
|
||||||
|
)
|
||||||
|
|
||||||
|
if object_type == "domains":
|
||||||
|
if not "system" in session["acl"]:
|
||||||
|
raise ValueError("name", "You need system permission to create a domain")
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
insert_data = create_object.model_dump(mode="json")
|
||||||
|
db.table(object_type).insert(insert_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
del IN_MEMORY_DB["FORM_OPTIONS_CACHE"][session.get("id")][object_type]
|
||||||
|
finally:
|
||||||
|
return insert_data["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
object_type: Literal[*model_classes["types"]],
|
||||||
|
object_id: UUID | None = None,
|
||||||
|
match_all: dict = {},
|
||||||
|
match_any: dict = {},
|
||||||
|
fully_resolve: bool = False,
|
||||||
|
):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
def search_object_id(s):
|
||||||
|
return (object_id and str(object_id) == s) or not object_id
|
||||||
|
|
||||||
|
def filter_details(s, _any: bool = False):
|
||||||
|
def match(key, value, current_data):
|
||||||
|
if key in current_data:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(item in ensure_list(current_data[key]) for item in value)
|
||||||
|
|
||||||
|
return value in current_data[key]
|
||||||
|
|
||||||
|
for sub_key, sub_value in current_data.items():
|
||||||
|
if isinstance(sub_value, dict):
|
||||||
|
if match(key, value, sub_value): # Recursive call
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if _any:
|
||||||
|
return any(match(k, v, s) for k, v in match_any.items())
|
||||||
|
|
||||||
|
return all(match(k, v, s) for k, v in match_all.items())
|
||||||
|
|
||||||
|
query = Query().id.test(search_object_id)
|
||||||
|
if match_all:
|
||||||
|
query = query & Query().details.test(filter_details)
|
||||||
|
if match_any:
|
||||||
|
query = query & Query().details.test(filter_details, True)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
matches = db.table(object_type).search(query)
|
||||||
|
|
||||||
|
if fully_resolve:
|
||||||
|
return ensure_list(
|
||||||
|
await get(
|
||||||
|
object_type=object_type,
|
||||||
|
object_id=[o["id"] for o in matches],
|
||||||
|
permission_validation=False,
|
||||||
|
)
|
||||||
|
or []
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_parsed = []
|
||||||
|
for o in matches:
|
||||||
|
_parsed.append(model_classes["base"][object_type].model_validate(o))
|
||||||
|
return _parsed
|
||||||
18
components/system.py
Normal file
18
components/system.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
import glob
|
||||||
|
from components.models.system import SystemSettingsBase
|
||||||
|
from components.database import TinyDB, TINYDB_PARAMS
|
||||||
|
|
||||||
|
|
||||||
|
async def get_system_settings():
|
||||||
|
async with TinyDB(**TINYDB_PARAMS) as db:
|
||||||
|
settings = db.table("system_settings").get(doc_id=1) or {}
|
||||||
|
return SystemSettingsBase.parse_obj(settings).dict()
|
||||||
|
|
||||||
|
|
||||||
|
def list_application_log_files():
|
||||||
|
yield "logs/application.log"
|
||||||
|
for peer_dir in glob.glob("peer_files/*"):
|
||||||
|
peer_log = os.path.join(peer_dir, "logs/application.log")
|
||||||
|
if os.path.isfile(peer_log):
|
||||||
|
yield peer_log
|
||||||
228
components/users.py
Normal file
228
components/users.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
from components.models.users import (
|
||||||
|
AddCredential,
|
||||||
|
CredentialPatch,
|
||||||
|
Credential,
|
||||||
|
User,
|
||||||
|
UserAdd,
|
||||||
|
UserPatch,
|
||||||
|
UserProfile,
|
||||||
|
UserProfilePatch,
|
||||||
|
UserSession,
|
||||||
|
constr,
|
||||||
|
validate_call,
|
||||||
|
UUID,
|
||||||
|
)
|
||||||
|
from components.utils import merge_models
|
||||||
|
from components.database import *
|
||||||
|
from components.cache import buster
|
||||||
|
|
||||||
|
|
||||||
|
def _create_credentials_mapping(credentials: dict):
|
||||||
|
user_credentials = dict()
|
||||||
|
for c in credentials:
|
||||||
|
user_credentials.update({c["id"]: Credential.model_validate(c)})
|
||||||
|
return user_credentials
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def what_id(login: str):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
user = db.table("users").get(Query().login == login)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return user["id"]
|
||||||
|
else:
|
||||||
|
raise ValueError("login", "The provided login name is unknown")
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def create(data: dict):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
create_user = UserAdd.model_validate(data)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
if db.table("users").search(Query().login == create_user.login):
|
||||||
|
raise ValueError("name", "The provided login name exists")
|
||||||
|
insert_data = create_user.model_dump(mode="json")
|
||||||
|
db.table("users").insert(insert_data)
|
||||||
|
|
||||||
|
for user_id in IN_MEMORY_DB["FORM_OPTIONS_CACHE"].copy():
|
||||||
|
if "users" in IN_MEMORY_DB["FORM_OPTIONS_CACHE"][user_id]:
|
||||||
|
del IN_MEMORY_DB["FORM_OPTIONS_CACHE"][user_id]["users"]
|
||||||
|
|
||||||
|
return insert_data["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def get(user_id: UUID, join_credentials: bool = True):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
user = User.model_validate(db.table("users").get(Query().id == str(user_id)))
|
||||||
|
credentials = db.table("credentials").search(
|
||||||
|
(Query().id.one_of(user.credentials))
|
||||||
|
)
|
||||||
|
if join_credentials:
|
||||||
|
user.credentials = _create_credentials_mapping(credentials)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def delete(user_id: UUID):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
user = await get(user_id=user_id, join_credentials=False)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise ValueError("name", "The provided user does not exist")
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
if len(db.table("users").all()) == 1:
|
||||||
|
raise ValueError("name", "Cannot delete last user")
|
||||||
|
|
||||||
|
db.table("credentials").remove(Query().id.one_of(user.credentials))
|
||||||
|
deleted = db.table("users").remove(Query().id == str(user_id))
|
||||||
|
buster(user.id)
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def create_credential(user_id: UUID, data: dict):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
credential = AddCredential.model_validate(data)
|
||||||
|
user = await get(user_id=user_id, join_credentials=False)
|
||||||
|
if not user:
|
||||||
|
raise ValueError("name", "The provided user does not exist")
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
db.table("credentials").insert(credential.model_dump(mode="json"))
|
||||||
|
user.credentials.append(credential.id)
|
||||||
|
db.table("users").update(
|
||||||
|
{"credentials": user.credentials},
|
||||||
|
Query().id == str(user_id),
|
||||||
|
)
|
||||||
|
return credential.id
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def delete_credential(
|
||||||
|
user_id: UUID, hex_id: constr(pattern=r"^[0-9a-fA-F]+$", min_length=2)
|
||||||
|
):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
user = await get(user_id=user_id, join_credentials=False)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise ValueError("name", "The provided user does not exist")
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
if hex_id in user.credentials:
|
||||||
|
user.credentials.remove(hex_id)
|
||||||
|
db.table("credentials").remove(Query().id == hex_id)
|
||||||
|
db.table("users").update(
|
||||||
|
{"credentials": user.credentials}, Query().id == str(user_id)
|
||||||
|
)
|
||||||
|
return hex_id
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def patch(user_id: UUID, data: dict):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
user = await get(user_id=user_id, join_credentials=False)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise ValueError("name", "The provided user does not exist")
|
||||||
|
|
||||||
|
patch_data = UserPatch.model_validate(data)
|
||||||
|
patched_user = merge_models(
|
||||||
|
user,
|
||||||
|
patch_data,
|
||||||
|
exclude_strategies=["exclude_override_none"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
if db.table("users").get(
|
||||||
|
(Query().login == patched_user.login) & (Query().id != str(user_id))
|
||||||
|
):
|
||||||
|
raise ValueError("login", "The provided login name exists")
|
||||||
|
|
||||||
|
orphaned_credentials = [
|
||||||
|
c for c in user.credentials if c not in patched_user.credentials
|
||||||
|
]
|
||||||
|
db.table("users").update(
|
||||||
|
patched_user.model_dump(mode="json"),
|
||||||
|
Query().id == str(user_id),
|
||||||
|
)
|
||||||
|
db.table("credentials").remove(Query().id.one_of(orphaned_credentials))
|
||||||
|
|
||||||
|
buster(user.id)
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def patch_profile(user_id: UUID, data: dict):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
user = await get(user_id=user_id, join_credentials=False)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise ValueError("name", "The provided user does not exist")
|
||||||
|
|
||||||
|
patch_data = UserProfilePatch.model_validate(data)
|
||||||
|
patched_user_profile = merge_models(
|
||||||
|
user.profile, patch_data, exclude_strategies=["exclude_override_none"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
db.table("users").update(
|
||||||
|
{"profile": patched_user_profile.model_dump(mode="json")},
|
||||||
|
Query().id == str(user_id),
|
||||||
|
)
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def patch_credential(
|
||||||
|
user_id: UUID, hex_id: constr(pattern=r"^[0-9a-fA-F]+$", min_length=2), data: dict
|
||||||
|
):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
user = await get(user_id=user_id, join_credentials=True)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise ValueError("name", "The provided user does not exist")
|
||||||
|
|
||||||
|
if hex_id not in user.credentials:
|
||||||
|
raise ValueError(
|
||||||
|
"hex_id",
|
||||||
|
"The provided credential ID was not found in user context",
|
||||||
|
)
|
||||||
|
|
||||||
|
patch_data = CredentialPatch.model_validate(data)
|
||||||
|
patched_credential = merge_models(
|
||||||
|
user.credentials[hex_id],
|
||||||
|
patch_data,
|
||||||
|
exclude_strategies=["exclude_override_none"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
db.table("credentials").update(
|
||||||
|
patched_credential.model_dump(mode="json"), Query().id == hex_id
|
||||||
|
)
|
||||||
|
return hex_id
|
||||||
|
|
||||||
|
|
||||||
|
@validate_call
|
||||||
|
async def search(
|
||||||
|
name: constr(strip_whitespace=True, min_length=0), join_credentials: bool = True
|
||||||
|
):
|
||||||
|
db_params = evaluate_db_params()
|
||||||
|
|
||||||
|
def search_name(s):
|
||||||
|
return name in s
|
||||||
|
|
||||||
|
async with TinyDB(**db_params) as db:
|
||||||
|
matches = db.table("users").search(Query().login.test(search_name))
|
||||||
|
|
||||||
|
return [
|
||||||
|
await get(user["id"], join_credentials=join_credentials) for user in matches
|
||||||
|
]
|
||||||
99
components/utils/__init__.py
Normal file
99
components/utils/__init__.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from components.models import Any, BaseModel, Literal, UUID
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"merge_models",
|
||||||
|
"merge_deep",
|
||||||
|
"batch",
|
||||||
|
"ensure_list",
|
||||||
|
"to_unique_sorted_str_list",
|
||||||
|
"is_path_within_cwd",
|
||||||
|
"expire_key",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Returns new model (based on original model) using
|
||||||
|
# deep merged data of both input models data
|
||||||
|
def merge_models(
|
||||||
|
original: BaseModel,
|
||||||
|
override: BaseModel,
|
||||||
|
exclude_strategies: list = Literal[
|
||||||
|
"exclude_original_none",
|
||||||
|
"exclude_original_unset",
|
||||||
|
"exclude_override_none",
|
||||||
|
"exclude_override_unset",
|
||||||
|
],
|
||||||
|
) -> BaseModel:
|
||||||
|
# Convert both models to dictionaries
|
||||||
|
original_data = original.model_dump(
|
||||||
|
mode="json",
|
||||||
|
exclude_none=True if "exclude_original_none" in exclude_strategies else False,
|
||||||
|
exclude_unset=True if "exclude_original_unset" in exclude_strategies else False,
|
||||||
|
)
|
||||||
|
override_data = override.model_dump(
|
||||||
|
mode="json",
|
||||||
|
exclude_none=True if "exclude_override_none" in exclude_strategies else False,
|
||||||
|
exclude_unset=True if "exclude_override_unset" in exclude_strategies else False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with override taking priority
|
||||||
|
merged_data = merge_deep(original_data, override_data)
|
||||||
|
|
||||||
|
# Return a revalidated model using the original model's class
|
||||||
|
return original.__class__(**merged_data)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_deep(original_data: dict, override_data: dict):
|
||||||
|
result = deepcopy(original_data)
|
||||||
|
|
||||||
|
def _recursive_merge(_original_data, _override_data):
|
||||||
|
for key in _override_data:
|
||||||
|
if (
|
||||||
|
key in _original_data
|
||||||
|
and isinstance(_original_data[key], dict)
|
||||||
|
and isinstance(_override_data[key], dict)
|
||||||
|
):
|
||||||
|
_recursive_merge(_original_data[key], _override_data[key])
|
||||||
|
else:
|
||||||
|
_original_data[key] = _override_data[key]
|
||||||
|
|
||||||
|
_recursive_merge(result, override_data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def batch(l: list, n: int):
|
||||||
|
_l = len(l)
|
||||||
|
for ndx in range(0, _l, n):
|
||||||
|
yield l[ndx : min(ndx + n, _l)]
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_string(s, size=1_000_000):
|
||||||
|
return [s[i : i + size] for i in range(0, len(s), size)]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_list(a: Any | list[Any] | None) -> list:
|
||||||
|
if a:
|
||||||
|
if not isinstance(a, list):
|
||||||
|
return [a]
|
||||||
|
return a
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def to_unique_sorted_str_list(l: list[str | UUID]) -> list:
|
||||||
|
_l = [str(x) for x in set(l) if x]
|
||||||
|
return sorted(_l)
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_within_cwd(path):
|
||||||
|
requested_path = os.path.abspath(path)
|
||||||
|
return requested_path.startswith(os.path.abspath("."))
|
||||||
|
|
||||||
|
|
||||||
|
async def expire_key(in_dict: dict, dict_key: int | str, wait_time: float | int):
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
in_dict.pop(dict_key, None)
|
||||||
129
components/utils/cryptography.py
Normal file
129
components/utils/cryptography.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.fernet import InvalidToken
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives import padding
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InvalidToken",
|
||||||
|
"fernet_encrypt",
|
||||||
|
"fernet_decrypt",
|
||||||
|
"aes_cbc_encrypt",
|
||||||
|
"aes_cbc_decrypt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_rsa_dkim(key_size, public_exponent=65537):
|
||||||
|
private_key = rsa.generate_private_key(
|
||||||
|
public_exponent=public_exponent,
|
||||||
|
key_size=key_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
public_key_bytes = private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.DER,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
private_key_pem_bytes = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
|
||||||
|
public_key_string = base64.b64encode(public_key_bytes).decode("utf-8")
|
||||||
|
private_key_pem_string = private_key_pem_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
return private_key_pem_string, public_key_string
|
||||||
|
|
||||||
|
|
||||||
|
def file_digest_sha256(filename: str):
|
||||||
|
with open(filename, "rb", buffering=0) as f:
|
||||||
|
return hashlib.file_digest(f, "256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def dict_digest_sha1(d: str):
|
||||||
|
return hashlib.sha1(json.dumps(d, sort_keys=True).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def dict_digest_sha256(d: str):
|
||||||
|
return hashlib.sha256(json.dumps(d, sort_keys=True).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def aes_cbc_encrypt(
|
||||||
|
data: str, code: str, iv: bytes = os.urandom(16), salt: bytes = os.urandom(16)
|
||||||
|
) -> bytes:
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
key = kdf.derive(code.encode("utf-8"))
|
||||||
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||||||
|
padder = padding.PKCS7(algorithms.AES.block_size).padder()
|
||||||
|
padded_data = padder.update(data.encode("utf-8")) + padder.finalize()
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
||||||
|
return salt + iv + ciphertext
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def aes_cbc_decrypt(data: bytes, code: str) -> str:
|
||||||
|
salt = data[:16]
|
||||||
|
iv = data[16:32]
|
||||||
|
ciphertext = data[32:]
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
key = kdf.derive(code.encode("utf-8"))
|
||||||
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||||
|
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
||||||
|
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
|
||||||
|
return plaintext.decode(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def fernet_encrypt(data: str, code: str, salt: bytes = os.urandom(16)) -> bytes:
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
fernet = Fernet(
|
||||||
|
base64.urlsafe_b64encode(kdf.derive(code.encode("utf-8"))),
|
||||||
|
)
|
||||||
|
return base64.urlsafe_b64encode(salt + fernet.encrypt(data.encode("utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def fernet_decrypt(data: str, code: str) -> str:
|
||||||
|
data = base64.urlsafe_b64decode(data)
|
||||||
|
salt = data[:16]
|
||||||
|
encrypted_data = data[16:]
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=salt,
|
||||||
|
iterations=100000,
|
||||||
|
backend=default_backend(),
|
||||||
|
)
|
||||||
|
fernet = Fernet(base64.urlsafe_b64encode(kdf.derive(code.encode("utf-8"))))
|
||||||
|
return fernet.decrypt(encrypted_data).decode(encoding="utf-8")
|
||||||
28
components/utils/datetimes.py
Normal file
28
components/utils/datetimes.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, UTC, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def system_now_as_str():
|
||||||
|
return datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
|
||||||
|
|
||||||
|
def ntime_utc_now():
|
||||||
|
return datetime.now(UTC).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_as_str(dtformat="%Y-%m-%dT%H:%M:%S%z"):
|
||||||
|
return datetime.now(timezone.utc).strftime(dtformat)
|
||||||
|
|
||||||
|
|
||||||
|
def last_modified_http(file):
|
||||||
|
try:
|
||||||
|
last_modified_time = os.path.getmtime(file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
last_modified_time = 0
|
||||||
|
return datetime.utcfromtimestamp(last_modified_time).strftime(
|
||||||
|
"%a, %d %b %Y %H:%M:%S GMT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_last_modified_http(last_modified_http):
|
||||||
|
return datetime.strptime(last_modified_http, "%a, %d %b %Y %H:%M:%S GMT")
|
||||||
149
components/web/__init__.py
Normal file
149
components/web/__init__.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from config import defaults
|
||||||
|
from components.database import IN_MEMORY_DB
|
||||||
|
from components.utils import merge_deep, ensure_list
|
||||||
|
from components.utils.datetimes import ntime_utc_now
|
||||||
|
from components.web.blueprints import *
|
||||||
|
from components.web.utils import *
|
||||||
|
|
||||||
|
app = Quart(
|
||||||
|
__name__,
|
||||||
|
static_url_path="/static",
|
||||||
|
static_folder="static_files",
|
||||||
|
template_folder="templates",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.register_blueprint(root.blueprint)
|
||||||
|
app.register_blueprint(auth.blueprint)
|
||||||
|
app.register_blueprint(objects.blueprint)
|
||||||
|
app.register_blueprint(profile.blueprint)
|
||||||
|
app.register_blueprint(system.blueprint)
|
||||||
|
app.register_blueprint(users.blueprint)
|
||||||
|
|
||||||
|
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = defaults.SEND_FILE_MAX_AGE_DEFAULT
|
||||||
|
app.config["SECRET_KEY"] = defaults.SECRET_KEY
|
||||||
|
app.config["TEMPLATES_AUTO_RELOAD"] = defaults.TEMPLATES_AUTO_RELOAD
|
||||||
|
app.config["SERVER_NAME"] = defaults.HOSTNAME
|
||||||
|
app.config["MOD_REQ_LIMIT"] = 10
|
||||||
|
IN_MEMORY_DB["SESSION_VALIDATED"] = dict()
|
||||||
|
IN_MEMORY_DB["WS_CONNECTIONS"] = dict()
|
||||||
|
IN_MEMORY_DB["FORM_OPTIONS_CACHE"] = dict()
|
||||||
|
IN_MEMORY_DB["OBJECTS_CACHE"] = dict()
|
||||||
|
IN_MEMORY_DB["APP_LOGS_FULL_PULL"] = dict()
|
||||||
|
IN_MEMORY_DB["PROMOTE_USERS"] = set()
|
||||||
|
|
||||||
|
modifying_request_limiter = asyncio.Semaphore(app.config["MOD_REQ_LIMIT"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def load_context():
|
||||||
|
enforce_dbupdate = IN_MEMORY_DB.get("ENFORCE_DBUPDATE", False)
|
||||||
|
if enforce_dbupdate:
|
||||||
|
enforce_dbupdate = defaults.CLUSTER_ENFORCE_DBUPDATE_TIMEOUT - (
|
||||||
|
round(ntime_utc_now() - enforce_dbupdate)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ENFORCE_DBUPDATE": enforce_dbupdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(ClusterHTTPException)
|
||||||
|
async def handle_cluster_error(error):
|
||||||
|
error_msg = str(error.description)
|
||||||
|
await ws_htmx(
|
||||||
|
"system",
|
||||||
|
"beforeend",
|
||||||
|
"""<div hidden _="on load trigger
|
||||||
|
notification(
|
||||||
|
title: 'Cluster error',
|
||||||
|
level: 'system',
|
||||||
|
message: '{error}',
|
||||||
|
duration: 10000
|
||||||
|
)"></div>""".format(
|
||||||
|
error=error_msg
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_body="",
|
||||||
|
response_code=error.code,
|
||||||
|
title="Cluster error",
|
||||||
|
message=error_msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
async def before_request():
|
||||||
|
request.form_parsed = {}
|
||||||
|
request.locked = False
|
||||||
|
|
||||||
|
if session.get("id") and session["id"] in IN_MEMORY_DB["PROMOTE_USERS"]:
|
||||||
|
IN_MEMORY_DB["PROMOTE_USERS"].discard(session["id"])
|
||||||
|
user = await get(user_id=session["id"])
|
||||||
|
if "system" not in user.acl:
|
||||||
|
user.acl.append("system")
|
||||||
|
session["acl"] = user.acl
|
||||||
|
IN_MEMORY_DB["SESSION_VALIDATED"].update({session["id"]: user.acl})
|
||||||
|
|
||||||
|
if request.method in ["POST", "PATCH", "PUT", "DELETE"]:
|
||||||
|
await modifying_request_limiter.acquire()
|
||||||
|
form = await request.form
|
||||||
|
request.form_parsed = dict()
|
||||||
|
if form:
|
||||||
|
for k in form:
|
||||||
|
v = form.getlist(k)
|
||||||
|
if len(v) == 1:
|
||||||
|
request.form_parsed = merge_deep(
|
||||||
|
request.form_parsed, parse_form_to_dict(k, v.pop())
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
request.form_parsed = merge_deep(
|
||||||
|
request.form_parsed, parse_form_to_dict(k, v)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
async def after_request(response):
|
||||||
|
if defaults.DISABLE_CACHE == False:
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=0"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.teardown_request
|
||||||
|
async def teardown_request(exc):
|
||||||
|
modifying_request_limiter.release()
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def load_defaults():
|
||||||
|
_defaults = {
|
||||||
|
k: v
|
||||||
|
for k, v in defaults.__dict__.items()
|
||||||
|
if not (k.startswith("__") or k.startswith("_"))
|
||||||
|
}
|
||||||
|
return _defaults
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter(name="hex")
|
||||||
|
def to_hex(value):
|
||||||
|
return value.hex()
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter(name="ensurelist")
|
||||||
|
def ensurelist(value):
|
||||||
|
return ensure_list(value)
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter(name="toprettyjson")
|
||||||
|
def to_prettyjson(value):
|
||||||
|
return json.dumps(value, sort_keys=True, indent=2, separators=(",", ": "))
|
||||||
6
components/web/blueprints/__init__.py
Normal file
6
components/web/blueprints/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from components.web.blueprints import auth
|
||||||
|
from components.web.blueprints import objects
|
||||||
|
from components.web.blueprints import profile
|
||||||
|
from components.web.blueprints import root
|
||||||
|
from components.web.blueprints import system
|
||||||
|
from components.web.blueprints import users
|
||||||
647
components/web/blueprints/auth.py
Normal file
647
components/web/blueprints/auth.py
Normal file
@ -0,0 +1,647 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from components.models.users import (
|
||||||
|
UserSession,
|
||||||
|
AuthToken,
|
||||||
|
TokenConfirmation,
|
||||||
|
TypeAdapter,
|
||||||
|
uuid4,
|
||||||
|
)
|
||||||
|
from components.web.utils import *
|
||||||
|
from secrets import token_urlsafe
|
||||||
|
from components.utils import expire_key
|
||||||
|
from components.utils.datetimes import utc_now_as_str
|
||||||
|
from components.users import (
|
||||||
|
get as get_user,
|
||||||
|
what_id,
|
||||||
|
patch_credential,
|
||||||
|
create as create_user,
|
||||||
|
create_credential,
|
||||||
|
)
|
||||||
|
from webauthn import (
|
||||||
|
generate_registration_options,
|
||||||
|
options_to_json,
|
||||||
|
verify_registration_response,
|
||||||
|
verify_authentication_response,
|
||||||
|
generate_authentication_options,
|
||||||
|
)
|
||||||
|
from webauthn.helpers import (
|
||||||
|
parse_registration_credential_json,
|
||||||
|
parse_authentication_credential_json,
|
||||||
|
)
|
||||||
|
from webauthn.helpers.structs import (
|
||||||
|
AuthenticatorSelectionCriteria,
|
||||||
|
AttestationConveyancePreference,
|
||||||
|
UserVerificationRequirement,
|
||||||
|
ResidentKeyRequirement,
|
||||||
|
PublicKeyCredentialDescriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
blueprint = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
# A link to be sent to a user to login using webauthn authentication
|
||||||
|
# /auth/login/request/confirm/<request_token>
|
||||||
|
@blueprint.route("/login/request/confirm/<request_token>")
|
||||||
|
async def login_request_confirm(request_token: str):
|
||||||
|
try:
|
||||||
|
TypeAdapter(str).validate_python(request_token)
|
||||||
|
except:
|
||||||
|
return "", 200, {"HX-Redirect": "/"}
|
||||||
|
|
||||||
|
token_status = IN_MEMORY_DB.get(request_token, {}).get("status")
|
||||||
|
|
||||||
|
if token_status == "awaiting":
|
||||||
|
session["request_token"] = request_token
|
||||||
|
requested_login = IN_MEMORY_DB[request_token]["requested_login"]
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"auth/login/request/confirm.html",
|
||||||
|
login=requested_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
session["request_token"] = None
|
||||||
|
|
||||||
|
return "", 200, {"HX-Redirect": "/profile", "HX-Refresh": False}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/register/request/confirm/<login>", methods=["POST", "GET"])
|
||||||
|
@acl("system")
|
||||||
|
async def register_request_confirm_modal(login: str):
|
||||||
|
return await render_template("auth/register/request/confirm.html")
|
||||||
|
|
||||||
|
|
||||||
|
# As shown to user that is currently logged in
|
||||||
|
# /auth/login/request/confirm/modal/<request_token>
|
||||||
|
@blueprint.route(
|
||||||
|
"/login/request/confirm/internal/<request_token>", methods=["POST", "GET"]
|
||||||
|
)
|
||||||
|
@acl("any")
|
||||||
|
async def login_request_confirm_modal(request_token: str):
|
||||||
|
try:
|
||||||
|
TypeAdapter(str).validate_python(request_token)
|
||||||
|
except:
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if (
|
||||||
|
request_token in IN_MEMORY_DB
|
||||||
|
and IN_MEMORY_DB[request_token]["status"] == "awaiting"
|
||||||
|
):
|
||||||
|
IN_MEMORY_DB[request_token].update(
|
||||||
|
{
|
||||||
|
"status": "confirmed",
|
||||||
|
"credential_id": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
request_token,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws_htmx(session["login"], "delete:#auth-login-request", "")
|
||||||
|
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="warning",
|
||||||
|
response_code=403,
|
||||||
|
title="Confirmation failed",
|
||||||
|
message="Token denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template("auth/login/request/internal/confirm.html")
|
||||||
|
|
||||||
|
|
||||||
|
# An unknown user issues a login request to users that are currently logged in
|
||||||
|
# /auth/login/request/start
|
||||||
|
@blueprint.route("/login/request/start", methods=["POST"])
|
||||||
|
async def login_request_start():
|
||||||
|
try:
|
||||||
|
request_data = AuthToken.parse_obj(request.form_parsed)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
session_clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = await what_id(login=request_data.login)
|
||||||
|
user = await get_user(user_id=user_id)
|
||||||
|
except (ValidationError, ValueError):
|
||||||
|
return validation_error([{"loc": ["login"], "msg": f"User is not available"}])
|
||||||
|
|
||||||
|
request_token = token_urlsafe()
|
||||||
|
|
||||||
|
IN_MEMORY_DB[request_token] = {
|
||||||
|
"intention": f"Authenticate user: {request_data.login}",
|
||||||
|
"status": "awaiting",
|
||||||
|
"token_type": "web_confirmation",
|
||||||
|
"requested_login": request_data.login,
|
||||||
|
}
|
||||||
|
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
request_token,
|
||||||
|
defaults.AUTH_REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.profile.permit_auth_requests:
|
||||||
|
await ws_htmx(
|
||||||
|
request_data.login,
|
||||||
|
"beforeend",
|
||||||
|
f'<div id="auth-permit" hx-trigger="load" hx-get="/auth/login/request/confirm/internal/{request_token}"></div>',
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"auth/login/request/start.html",
|
||||||
|
data={
|
||||||
|
"request_token": request_token,
|
||||||
|
"request_issued_to_user": user.profile.permit_auth_requests,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Polled every second by unknown user that issued a login request
|
||||||
|
# /auth/login/request/check/<request_token>
|
||||||
|
@blueprint.route("/login/request/check/<request_token>")
|
||||||
|
async def login_request_check(request_token: str):
|
||||||
|
try:
|
||||||
|
TypeAdapter(str).validate_python(request_token)
|
||||||
|
except:
|
||||||
|
session.clear()
|
||||||
|
return "", 200, {"HX-Redirect": "/"}
|
||||||
|
|
||||||
|
token_status, requested_login, credential_id = map(
|
||||||
|
IN_MEMORY_DB.get(request_token, {}).get,
|
||||||
|
["status", "requested_login", "credential_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_status == "confirmed":
|
||||||
|
try:
|
||||||
|
user_id = await what_id(login=requested_login)
|
||||||
|
user = await get_user(user_id=user_id)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(
|
||||||
|
[{"loc": ["login"], "msg": f"User is not available"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
for k, v in (
|
||||||
|
UserSession(
|
||||||
|
login=user.login,
|
||||||
|
id=user.id,
|
||||||
|
acl=user.acl,
|
||||||
|
cred_id=credential_id,
|
||||||
|
lang=request.accept_languages.best_match(defaults.ACCEPT_LANGUAGES),
|
||||||
|
profile=user.profile,
|
||||||
|
)
|
||||||
|
.dict()
|
||||||
|
.items()
|
||||||
|
):
|
||||||
|
session[k] = v
|
||||||
|
|
||||||
|
else:
|
||||||
|
if token_status:
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
return "", 200, {"HX-Redirect": "/"}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/login/token", methods=["POST"])
|
||||||
|
async def login_token():
|
||||||
|
try:
|
||||||
|
request_data = AuthToken.parse_obj(request.form_parsed)
|
||||||
|
token = request_data.token
|
||||||
|
IN_MEMORY_DB[token] = {
|
||||||
|
"intention": f"Authenticate user: {request_data.login}",
|
||||||
|
"status": "awaiting",
|
||||||
|
"token_type": "cli_confirmation",
|
||||||
|
"login": request_data.login,
|
||||||
|
}
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
token,
|
||||||
|
120,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"auth/login/token.html",
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/login/token/verify", methods=["POST"])
|
||||||
|
async def login_token_verify():
|
||||||
|
try:
|
||||||
|
request_data = TokenConfirmation.parse_obj(request.form_parsed)
|
||||||
|
|
||||||
|
token_status, token_login, token_confirmation_code = map(
|
||||||
|
IN_MEMORY_DB.get(request_data.token, {}).get,
|
||||||
|
["status", "login", "code"],
|
||||||
|
)
|
||||||
|
IN_MEMORY_DB.pop(request_data.token, None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
token_status != "confirmed"
|
||||||
|
or token_confirmation_code != request_data.confirmation_code
|
||||||
|
):
|
||||||
|
return validation_error(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"loc": ["confirmation_code"],
|
||||||
|
"msg": "Confirmation code is invalid",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = await what_id(login=token_login)
|
||||||
|
user = await get_user(user_id=user_id)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
for k, v in (
|
||||||
|
UserSession(
|
||||||
|
login=token_login,
|
||||||
|
id=user.id,
|
||||||
|
acl=user.acl,
|
||||||
|
lang=request.accept_languages.best_match(defaults.ACCEPT_LANGUAGES),
|
||||||
|
profile=user.profile,
|
||||||
|
)
|
||||||
|
.dict()
|
||||||
|
.items()
|
||||||
|
):
|
||||||
|
session[k] = v
|
||||||
|
|
||||||
|
return "", 200, {"HX-Redirect": "/profile", "HX-Refresh": False}
|
||||||
|
|
||||||
|
|
||||||
|
# Generate login options for webauthn authentication
|
||||||
|
@blueprint.route("/login/webauthn/options", methods=["POST"])
|
||||||
|
async def login_webauthn_options():
|
||||||
|
try:
|
||||||
|
user_id = await what_id(login=request.form_parsed.get("login"))
|
||||||
|
user = await get_user(user_id=user_id)
|
||||||
|
if not user.credentials:
|
||||||
|
raise ValidationError
|
||||||
|
except (ValidationError, ValueError):
|
||||||
|
return validation_error([{"loc": ["login"], "msg": f"User is not available"}])
|
||||||
|
|
||||||
|
allow_credentials = [
|
||||||
|
PublicKeyCredentialDescriptor(id=bytes.fromhex(c))
|
||||||
|
for c in user.credentials.keys()
|
||||||
|
]
|
||||||
|
|
||||||
|
options = generate_authentication_options(
|
||||||
|
rp_id=defaults.WEBAUTHN_RP_ID,
|
||||||
|
timeout=defaults.WEBAUTHN_CHALLENGE_TIMEOUT * 1000,
|
||||||
|
allow_credentials=allow_credentials,
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
session["webauthn_challenge_id"] = token_urlsafe()
|
||||||
|
|
||||||
|
IN_MEMORY_DB[session["webauthn_challenge_id"]] = {
|
||||||
|
"challenge": b64encode(options.challenge),
|
||||||
|
"login": user.login,
|
||||||
|
}
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
session["webauthn_challenge_id"],
|
||||||
|
defaults.WEBAUTHN_CHALLENGE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", 204, {"HX-Trigger": json.dumps({"startAuth": options_to_json(options)})}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/register/token", methods=["POST"])
|
||||||
|
async def register_token():
|
||||||
|
try:
|
||||||
|
request_data = AuthToken.parse_obj(request.form_parsed)
|
||||||
|
token = request_data.token
|
||||||
|
IN_MEMORY_DB[token] = {
|
||||||
|
"intention": f"Register user: {request_data.login}",
|
||||||
|
"status": "awaiting",
|
||||||
|
"token_type": "cli_confirmation",
|
||||||
|
"login": request_data.login,
|
||||||
|
}
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
token,
|
||||||
|
120,
|
||||||
|
)
|
||||||
|
await ws_htmx(
|
||||||
|
"_system",
|
||||||
|
"beforeend",
|
||||||
|
f'<div id="auth-permit" hx-trigger="load" hx-get="/auth/register/request/confirm/{request_data.login}"></div>',
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"auth/register/token.html",
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/register/webauthn/options", methods=["POST"])
|
||||||
|
async def register_webauthn_options():
|
||||||
|
if "token" in request.form_parsed:
|
||||||
|
try:
|
||||||
|
request_data = TokenConfirmation.parse_obj(request.form_parsed)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
token_status, token_login, token_confirmation_code = map(
|
||||||
|
IN_MEMORY_DB.get(request_data.token, {}).get,
|
||||||
|
["status", "login", "code"],
|
||||||
|
)
|
||||||
|
IN_MEMORY_DB.pop(request_data.token, None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
token_status != "confirmed"
|
||||||
|
or token_confirmation_code != request_data.confirmation_code
|
||||||
|
):
|
||||||
|
return validation_error(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"loc": ["confirmation_code"],
|
||||||
|
"msg": "Confirmation code is invalid",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
exclude_credentials = []
|
||||||
|
user_id = str(uuid4())
|
||||||
|
login = token_login
|
||||||
|
appending_passkey = False
|
||||||
|
else:
|
||||||
|
if not session.get("id"):
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Registration failed",
|
||||||
|
message="Something went wrong",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await get_user(user_id=session["id"])
|
||||||
|
|
||||||
|
exclude_credentials = [
|
||||||
|
PublicKeyCredentialDescriptor(id=bytes.fromhex(c))
|
||||||
|
for c in user.credentials.keys()
|
||||||
|
]
|
||||||
|
|
||||||
|
user_id = session["id"]
|
||||||
|
login = session["login"]
|
||||||
|
appending_passkey = True
|
||||||
|
|
||||||
|
options = generate_registration_options(
|
||||||
|
rp_name=defaults.WEBAUTHN_RP_NAME,
|
||||||
|
rp_id=defaults.WEBAUTHN_RP_ID,
|
||||||
|
user_id=user_id.encode("ascii"),
|
||||||
|
timeout=defaults.WEBAUTHN_CHALLENGE_TIMEOUT * 1000,
|
||||||
|
exclude_credentials=exclude_credentials,
|
||||||
|
user_name=login,
|
||||||
|
attestation=AttestationConveyancePreference.DIRECT,
|
||||||
|
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
resident_key=ResidentKeyRequirement.REQUIRED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
session["webauthn_challenge_id"] = token_urlsafe()
|
||||||
|
|
||||||
|
IN_MEMORY_DB[session["webauthn_challenge_id"]] = {
|
||||||
|
"challenge": b64encode(options.challenge),
|
||||||
|
"login": login,
|
||||||
|
"user_id": user_id,
|
||||||
|
"appending_passkey": appending_passkey,
|
||||||
|
}
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
session["webauthn_challenge_id"],
|
||||||
|
defaults.WEBAUTHN_CHALLENGE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", 204, {"HX-Trigger": json.dumps({"startReg": options_to_json(options)})}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/register/webauthn", methods=["POST"])
|
||||||
|
async def register_webauthn():
|
||||||
|
json_body = await request.json
|
||||||
|
|
||||||
|
webauthn_challenge_id = session.get("webauthn_challenge_id")
|
||||||
|
session["webauthn_challenge_id"] = None
|
||||||
|
|
||||||
|
challenge, login, user_id, appending_passkey = map(
|
||||||
|
IN_MEMORY_DB.get(webauthn_challenge_id, {}).get,
|
||||||
|
["challenge", "login", "user_id", "appending_passkey"],
|
||||||
|
)
|
||||||
|
IN_MEMORY_DB.pop(webauthn_challenge_id, None)
|
||||||
|
|
||||||
|
if not challenge:
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Registration session invalid",
|
||||||
|
message="Registration session invalid",
|
||||||
|
additional_triggers={"authRegFailed": "register"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
credential = parse_registration_credential_json(json_body)
|
||||||
|
verification = verify_registration_response(
|
||||||
|
credential=credential,
|
||||||
|
expected_challenge=b64decode(challenge),
|
||||||
|
expected_rp_id=defaults.WEBAUTHN_RP_ID,
|
||||||
|
expected_origin=f"https://{defaults.WEBAUTHN_RP_ORIGIN}",
|
||||||
|
require_user_verification=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Registration failed",
|
||||||
|
message="An error occured while verifying the credential",
|
||||||
|
additional_triggers={"authRegFailed": "register"},
|
||||||
|
)
|
||||||
|
|
||||||
|
credential_data = {
|
||||||
|
"id": verification.credential_id,
|
||||||
|
"public_key": verification.credential_public_key,
|
||||||
|
"sign_count": verification.sign_count,
|
||||||
|
"friendly_name": "Key Anú Reeves",
|
||||||
|
"transports": json_body.get("transports", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ClusterLock(["users", "credentials"], current_app):
|
||||||
|
if not appending_passkey:
|
||||||
|
user_id = await create_user(data={"login": login})
|
||||||
|
|
||||||
|
await create_credential(
|
||||||
|
user_id=user_id,
|
||||||
|
data={
|
||||||
|
"id": verification.credential_id,
|
||||||
|
"public_key": verification.credential_public_key,
|
||||||
|
"sign_count": verification.sign_count,
|
||||||
|
"transports": json_body.get("transports", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Registration failed",
|
||||||
|
message="An error occured verifying the registration",
|
||||||
|
additional_triggers={"authRegFailed": "register"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if appending_passkey:
|
||||||
|
await ws_htmx(
|
||||||
|
session["login"],
|
||||||
|
"beforeend",
|
||||||
|
f'<div id="after-cred-add" hx-sync="abort" hx-trigger="load delay:1s" hx-target="#body-main" hx-get="/profile"></div>',
|
||||||
|
)
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="New token registered",
|
||||||
|
message="A new token was appended to your account and can now be used to login",
|
||||||
|
)
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Welcome on board 👋",
|
||||||
|
message="Your account was created, you can now log in",
|
||||||
|
additional_triggers={"regCompleted": login},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/login/webauthn", methods=["POST"])
|
||||||
|
async def auth_login_verify():
|
||||||
|
json_body = await request.json
|
||||||
|
|
||||||
|
try:
|
||||||
|
webauthn_challenge_id = session.get("webauthn_challenge_id")
|
||||||
|
challenge, login = map(
|
||||||
|
IN_MEMORY_DB.get(webauthn_challenge_id, {}).get,
|
||||||
|
["challenge", "login"],
|
||||||
|
)
|
||||||
|
IN_MEMORY_DB.pop(webauthn_challenge_id, None)
|
||||||
|
session["webauthn_challenge_id"] = None
|
||||||
|
|
||||||
|
if not all([webauthn_challenge_id, challenge, login]):
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Verification failed",
|
||||||
|
message="Verification process timed out",
|
||||||
|
additional_triggers={"authRegFailed": "authenticate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_challenge = b64decode(challenge)
|
||||||
|
|
||||||
|
user_id = await what_id(login=login)
|
||||||
|
user = await get_user(user_id=user_id)
|
||||||
|
|
||||||
|
credential = parse_authentication_credential_json(json_body)
|
||||||
|
|
||||||
|
matched_user_credential = None
|
||||||
|
for k, v in user.credentials.items():
|
||||||
|
if bytes.fromhex(k) == credential.raw_id:
|
||||||
|
matched_user_credential = v
|
||||||
|
|
||||||
|
if not matched_user_credential:
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Verification failed",
|
||||||
|
message="No such credential in user realm",
|
||||||
|
additional_triggers={"authRegFailed": "authenticate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
verification = verify_authentication_response(
|
||||||
|
credential=credential,
|
||||||
|
expected_challenge=auth_challenge,
|
||||||
|
expected_rp_id=defaults.WEBAUTHN_RP_ORIGIN,
|
||||||
|
expected_origin=f"https://{defaults.WEBAUTHN_RP_ORIGIN}",
|
||||||
|
credential_public_key=matched_user_credential.public_key,
|
||||||
|
credential_current_sign_count=matched_user_credential.sign_count,
|
||||||
|
require_user_verification=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
matched_user_credential.last_login = utc_now_as_str()
|
||||||
|
if matched_user_credential.sign_count != 0:
|
||||||
|
matched_user_credential.sign_count = verification.new_sign_count
|
||||||
|
|
||||||
|
async with ClusterLock("credentials", current_app):
|
||||||
|
user_id = await what_id(login=login)
|
||||||
|
await patch_credential(
|
||||||
|
user_id=user_id,
|
||||||
|
hex_id=credential.raw_id.hex(),
|
||||||
|
data=matched_user_credential.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=409,
|
||||||
|
title="Verification failed",
|
||||||
|
message="An error occured verifying the credential",
|
||||||
|
additional_triggers={"authRegFailed": "authenticate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
request_token = session.get("request_token")
|
||||||
|
|
||||||
|
if request_token:
|
||||||
|
"""
|
||||||
|
Not setting session login and id for device that is confirming the proxy authentication
|
||||||
|
Gracing 10s for the awaiting party to catch up an almost expired key
|
||||||
|
"""
|
||||||
|
IN_MEMORY_DB[request_token].update(
|
||||||
|
{
|
||||||
|
"status": "confirmed",
|
||||||
|
"credential_id": credential.raw_id.hex(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
request_token,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
session["request_token"] = None
|
||||||
|
|
||||||
|
return "", 204, {"HX-Trigger": "proxyAuthSuccess"}
|
||||||
|
|
||||||
|
for k, v in (
|
||||||
|
UserSession(
|
||||||
|
login=user.login,
|
||||||
|
id=user.id,
|
||||||
|
acl=user.acl,
|
||||||
|
cred_id=credential.raw_id.hex(),
|
||||||
|
lang=request.accept_languages.best_match(defaults.ACCEPT_LANGUAGES),
|
||||||
|
profile=user.profile,
|
||||||
|
)
|
||||||
|
.dict()
|
||||||
|
.items()
|
||||||
|
):
|
||||||
|
session[k] = v
|
||||||
|
|
||||||
|
return "", 200, {"HX-Redirect": "/profile", "HX-Refresh": False}
|
||||||
265
components/web/blueprints/objects.py
Normal file
265
components/web/blueprints/objects.py
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import components.objects
|
||||||
|
|
||||||
|
from components.models.objects import model_classes, uuid4
|
||||||
|
from components.utils import batch, ensure_list
|
||||||
|
from components.web.utils import *
|
||||||
|
|
||||||
|
blueprint = Blueprint("objects", __name__, url_prefix="/objects")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.context_processor
|
||||||
|
async def load_context():
|
||||||
|
context = dict()
|
||||||
|
context["system_fields"] = {
|
||||||
|
object_type: system_fields
|
||||||
|
for object_type, system_fields in model_classes["system_fields"].items()
|
||||||
|
}
|
||||||
|
context["unique_fields"] = {
|
||||||
|
object_type: unique_fields
|
||||||
|
for object_type, unique_fields in model_classes["unique_fields"].items()
|
||||||
|
}
|
||||||
|
context["schemas"] = {
|
||||||
|
object_type: v.model_json_schema()
|
||||||
|
for object_type, v in model_classes["forms"].items()
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.before_request
|
||||||
|
async def objects_before_request():
|
||||||
|
if "object_type" in request.view_args:
|
||||||
|
if request.view_args["object_type"] not in model_classes["types"]:
|
||||||
|
if "Hx-Request" in request.headers:
|
||||||
|
return trigger_notification(
|
||||||
|
level="error",
|
||||||
|
response_code=204,
|
||||||
|
title="Object type error",
|
||||||
|
message="Object type is unknown",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return (f"<h1>Object type error</h1><p>Object type is unknown</p>", 409)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<object_type>/<object_id>")
|
||||||
|
@acl("user")
|
||||||
|
@formoptions(["emailusers", "domains", "keypairs", "users"])
|
||||||
|
async def get_object(object_type: str, object_id: str):
|
||||||
|
try:
|
||||||
|
object_data = await components.objects.get(
|
||||||
|
object_id=object_id, object_type=object_type
|
||||||
|
)
|
||||||
|
|
||||||
|
if not object_data:
|
||||||
|
return (f"<h1>Object not found</h1><p>Object is unknown</p>", 404)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Inject form options not provided by user permission.
|
||||||
|
A user will not be able to access detailed information about objects
|
||||||
|
with inherited permission nor will they be able to remove assignments.
|
||||||
|
"""
|
||||||
|
if object_type == "addresses":
|
||||||
|
object_domain = {
|
||||||
|
"name": object_data.details.assigned_domain.name,
|
||||||
|
"value": object_data.details.assigned_domain.id,
|
||||||
|
}
|
||||||
|
if object_domain not in request.form_options["domains"]:
|
||||||
|
object_domain["name"] = f"{object_data.details.assigned_domain.name} ⚠️"
|
||||||
|
request.form_options["domains"].append(object_domain)
|
||||||
|
|
||||||
|
for object_emailuser in object_data.details.assigned_emailusers:
|
||||||
|
if object_emailuser:
|
||||||
|
u = {
|
||||||
|
"name": object_emailuser.name,
|
||||||
|
"value": object_emailuser.id,
|
||||||
|
}
|
||||||
|
if u not in request.form_options["emailusers"]:
|
||||||
|
u["name"] = f"{object_emailuser.name} ⚠️"
|
||||||
|
request.form_options["emailusers"].append(u)
|
||||||
|
|
||||||
|
elif object_type == "domains":
|
||||||
|
object_keypair_injections = []
|
||||||
|
for attr in ["assigned_dkim_keypair", "assigned_arc_keypair"]:
|
||||||
|
object_keypair_details = getattr(object_data.details, attr)
|
||||||
|
if hasattr(object_keypair_details, "id"):
|
||||||
|
object_keypair = {
|
||||||
|
"name": object_keypair_details.name,
|
||||||
|
"value": object_keypair_details.id,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
object_keypair not in request.form_options["keypairs"]
|
||||||
|
and object_keypair not in object_keypair_injections
|
||||||
|
):
|
||||||
|
object_keypair_injections.append(object_keypair)
|
||||||
|
for keypair in object_keypair_injections:
|
||||||
|
keypair["name"] = keypair["name"] + " ⚠️"
|
||||||
|
request.form_options["keypairs"].append(keypair)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return await render_or_json(
|
||||||
|
"objects/object.html", request.headers, object=object_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<object_type>")
|
||||||
|
@blueprint.route("/<object_type>/search", methods=["POST"])
|
||||||
|
@acl("user")
|
||||||
|
@formoptions(["domains"])
|
||||||
|
async def get_objects(object_type: str):
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
search_model,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
sort_attr,
|
||||||
|
sort_reverse,
|
||||||
|
filters,
|
||||||
|
) = table_search_helper(
|
||||||
|
request.form_parsed, object_type, default_sort_attr="name"
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
match_any = {
|
||||||
|
"key_name": search_model.q,
|
||||||
|
"domain": search_model.q,
|
||||||
|
"local_part": search_model.q,
|
||||||
|
"username": search_model.q,
|
||||||
|
"assigned_domain": search_model.q,
|
||||||
|
}
|
||||||
|
match_all = (
|
||||||
|
{"assigned_users": [session["id"]]}
|
||||||
|
if not "system" in session["acl"]
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
matched_objects = await components.objects.search(
|
||||||
|
object_type=object_type,
|
||||||
|
match_any=match_any,
|
||||||
|
fully_resolve=True,
|
||||||
|
match_all=filters | match_all,
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
object_pages = [
|
||||||
|
m
|
||||||
|
for m in batch(
|
||||||
|
sorted(
|
||||||
|
matched_objects,
|
||||||
|
key=lambda x: getattr(x, sort_attr),
|
||||||
|
reverse=sort_reverse,
|
||||||
|
),
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
object_pages[page - 1]
|
||||||
|
except IndexError:
|
||||||
|
page = len(object_pages)
|
||||||
|
|
||||||
|
objects = object_pages[page - 1] if page else object_pages
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"objects/includes/objects/table_body.html",
|
||||||
|
data={
|
||||||
|
"objects": objects,
|
||||||
|
"page_size": page_size,
|
||||||
|
"page": page,
|
||||||
|
"pages": len(object_pages),
|
||||||
|
"elements": len(matched_objects),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await render_template(
|
||||||
|
"objects/objects.html", data={"object_type": object_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<object_type>", methods=["POST"])
|
||||||
|
@acl("user")
|
||||||
|
async def create_object(object_type: str):
|
||||||
|
try:
|
||||||
|
async with ClusterLock(object_type, current_app):
|
||||||
|
object_id = await components.objects.create(
|
||||||
|
object_type=object_type, data=request.form_parsed
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Object created",
|
||||||
|
message=f"Object {object_id} created",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<object_type>/delete", methods=["POST"])
|
||||||
|
@blueprint.route("/<object_type>/<object_id>", methods=["DELETE"])
|
||||||
|
@acl("user")
|
||||||
|
async def delete_object(object_type: str, object_id: str | None = None):
|
||||||
|
if request.method == "POST":
|
||||||
|
object_id = request.form_parsed.get("id")
|
||||||
|
try:
|
||||||
|
object_ids = ensure_list(object_id)
|
||||||
|
async with ClusterLock(object_type, current_app):
|
||||||
|
deleted_objects = await components.objects.delete(
|
||||||
|
object_id=object_ids, object_type=object_type
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Object removed",
|
||||||
|
message=f"{len(deleted_objects)} object{'s' if len(deleted_objects) > 1 else ''} removed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<object_type>/patch", methods=["POST"])
|
||||||
|
@blueprint.route("/<object_type>/<object_id>", methods=["PATCH"])
|
||||||
|
@acl("user")
|
||||||
|
async def patch_object(object_type: str, object_id: str | None = None):
|
||||||
|
if request.method == "POST":
|
||||||
|
object_id = request.form_parsed.get("id")
|
||||||
|
try:
|
||||||
|
async with ClusterLock(object_type, current_app):
|
||||||
|
patched_objects = await components.objects.patch(
|
||||||
|
object_id=object_id, object_type=object_type, data=request.form_parsed
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
await ws_htmx(
|
||||||
|
"_user",
|
||||||
|
"beforeend",
|
||||||
|
f'<div hx-trigger="load once" hx-sync="#object-details:drop" hx-target="#object-details" hx-select="#object-details" hx-select-oob="#object-name" hx-swap="outerHTML" hx-get="/objects/{object_type}/{object_id}"></div>',
|
||||||
|
f"/objects/{object_type}/{object_id}",
|
||||||
|
exclude_self=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success" if len(patched_objects) > 0 else "warning",
|
||||||
|
response_code=204,
|
||||||
|
title="Patch completed",
|
||||||
|
message=f"{len(patched_objects)} object{'s' if (len(patched_objects) > 1 or len(patched_objects) == 0) else ''} modified",
|
||||||
|
)
|
||||||
103
components/web/blueprints/profile.py
Normal file
103
components/web/blueprints/profile.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import components.users
|
||||||
|
|
||||||
|
from components.models.users import UserProfile
|
||||||
|
from components.web.utils import *
|
||||||
|
|
||||||
|
blueprint = Blueprint("profile", __name__, url_prefix="/profile")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.context_processor
|
||||||
|
def load_context():
|
||||||
|
context = dict()
|
||||||
|
context["schemas"] = {"user_profile": UserProfile.model_json_schema()}
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/")
|
||||||
|
@acl("any")
|
||||||
|
async def user_profile_get():
|
||||||
|
try:
|
||||||
|
user = await components.users.get(user_id=session["id"])
|
||||||
|
except ValidationError:
|
||||||
|
session_clear()
|
||||||
|
return redirect(url_for("root.root"))
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"profile/profile.html",
|
||||||
|
data={
|
||||||
|
"user": user.dict(),
|
||||||
|
"keypair": None,
|
||||||
|
"credentials": user.credentials,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/edit", methods=["PATCH"])
|
||||||
|
@acl("any")
|
||||||
|
async def user_profile_patch():
|
||||||
|
try:
|
||||||
|
async with ClusterLock("users", current_app):
|
||||||
|
await components.users.patch_profile(
|
||||||
|
user_id=session["id"], data=request.form_parsed
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await components.users.get(user_id=session["id"])
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
session.pop("profile", None)
|
||||||
|
session["profile"] = user.profile.dict()
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Profile updated",
|
||||||
|
message="Your profile was updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/credential/<credential_hex_id>", methods=["PATCH"])
|
||||||
|
@acl("any")
|
||||||
|
async def patch_credential(credential_hex_id: str):
|
||||||
|
try:
|
||||||
|
async with ClusterLock("credentials", current_app):
|
||||||
|
await components.users.patch_credential(
|
||||||
|
user_id=session["id"],
|
||||||
|
hex_id=credential_hex_id,
|
||||||
|
data=request.form_parsed,
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Credential modified",
|
||||||
|
message="Credential was modified",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/credential/<credential_hex_id>", methods=["DELETE"])
|
||||||
|
@acl("any")
|
||||||
|
async def delete_credential(credential_hex_id: str):
|
||||||
|
try:
|
||||||
|
async with ClusterLock(["credentials", "users"], current_app):
|
||||||
|
await components.users.delete_credential(
|
||||||
|
user_id=session["id"], hex_id=credential_hex_id
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Credential deleted",
|
||||||
|
message="Credential was removed",
|
||||||
|
)
|
||||||
46
components/web/blueprints/root.py
Normal file
46
components/web/blueprints/root.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from components.web.utils import *
|
||||||
|
from components.utils import is_path_within_cwd
|
||||||
|
from components.utils.datetimes import (
|
||||||
|
last_modified_http,
|
||||||
|
parse_last_modified_http,
|
||||||
|
ntime_utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
blueprint = Blueprint("main", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/")
|
||||||
|
async def root():
|
||||||
|
if session.get("id"):
|
||||||
|
return redirect(url_for("profile.user_profile_get"))
|
||||||
|
|
||||||
|
session_clear()
|
||||||
|
return await render_template("auth/authenticate.html")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/logout", methods=["POST", "GET"])
|
||||||
|
async def logout():
|
||||||
|
session_clear()
|
||||||
|
return ("", 200, {"HX-Redirect": "/"})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.websocket("/ws")
|
||||||
|
@websocket_acl("any")
|
||||||
|
async def ws():
|
||||||
|
while True:
|
||||||
|
await websocket.send(
|
||||||
|
f'<div class="no-text-decoration" data-tooltip="Connected" id="ws-indicator" hx-swap-oob="outerHTML">🟢</div>'
|
||||||
|
)
|
||||||
|
data = await websocket.receive()
|
||||||
|
try:
|
||||||
|
data_dict = json.loads(data)
|
||||||
|
if "path" in data_dict:
|
||||||
|
IN_MEMORY_DB["WS_CONNECTIONS"][session["login"]][
|
||||||
|
websocket._get_current_object()
|
||||||
|
]["path"] = data_dict["path"]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
302
components/web/blueprints/system.py
Normal file
302
components/web/blueprints/system.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import asyncio
|
||||||
|
import components.system
|
||||||
|
import fileinput
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from components.models.system import SystemSettings, UpdateSystemSettings
|
||||||
|
from components.utils import batch, expire_key
|
||||||
|
from components.utils.datetimes import datetime, ntime_utc_now
|
||||||
|
from components.web.utils import *
|
||||||
|
|
||||||
|
blueprint = Blueprint("system", __name__, url_prefix="/system")
|
||||||
|
log_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.context_processor
|
||||||
|
def load_context():
|
||||||
|
from components.cluster.cluster import cluster
|
||||||
|
|
||||||
|
context = dict()
|
||||||
|
context["schemas"] = {"system_settings": SystemSettings.model_json_schema()}
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/cluster/update-status", methods=["POST"])
|
||||||
|
@acl("system")
|
||||||
|
async def cluster_status_update():
|
||||||
|
ticket, receivers = await cluster.send_command("STATUS", "*")
|
||||||
|
async with cluster.receiving:
|
||||||
|
await cluster.await_receivers(ticket, receivers, raise_err=False)
|
||||||
|
|
||||||
|
return await status()
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/cluster/db/enforce-updates", methods=["POST"])
|
||||||
|
@acl("system")
|
||||||
|
async def cluster_db_enforce_updates():
|
||||||
|
toggle = request.form_parsed.get("toggle", "off")
|
||||||
|
if toggle == "on":
|
||||||
|
if not IN_MEMORY_DB.get("ENFORCE_DBUPDATE", False):
|
||||||
|
IN_MEMORY_DB["ENFORCE_DBUPDATE"] = ntime_utc_now()
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
"ENFORCE_DBUPDATE",
|
||||||
|
defaults.CLUSTER_ENFORCE_DBUPDATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
await ws_htmx(
|
||||||
|
"_system",
|
||||||
|
"beforeend",
|
||||||
|
"""<div hidden _="on load trigger
|
||||||
|
notification(
|
||||||
|
title: 'Cluster notification',
|
||||||
|
level: 'system',
|
||||||
|
message: 'Enforced database updates are enabled',
|
||||||
|
duration: 5000
|
||||||
|
)"></div>""",
|
||||||
|
)
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Activated",
|
||||||
|
message="Enforced database updates are enabled",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return trigger_notification(
|
||||||
|
level="warning",
|
||||||
|
response_code=409,
|
||||||
|
title="Already active",
|
||||||
|
message="Enforced database updates are already enabled",
|
||||||
|
)
|
||||||
|
elif toggle == "off":
|
||||||
|
IN_MEMORY_DB["ENFORCE_DBUPDATE"] = False
|
||||||
|
await ws_htmx(
|
||||||
|
"_system",
|
||||||
|
"beforeend",
|
||||||
|
'<div hidden _="on load remove #enforce-dbupdate-button then trigger '
|
||||||
|
+ "notification(title: 'Cluster notification', level: 'system', message: 'Enforced database updates are now disabled', duration: 5000)\"></div>",
|
||||||
|
)
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Deactivated",
|
||||||
|
message="Enforced transaction mode is disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/status", methods=["GET"])
|
||||||
|
@acl("system")
|
||||||
|
async def status():
|
||||||
|
status = {
|
||||||
|
"ENFORCE_DBUPDATE": IN_MEMORY_DB.get("ENFORCE_DBUPDATE", False),
|
||||||
|
"CLUSTER_PEERS_REMOTE_PEERS": cluster.peers.remotes,
|
||||||
|
"CLUSTER_PEERS_LOCAL": cluster.peers.local,
|
||||||
|
}
|
||||||
|
return await render_template("system/status.html", data={"status": status})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/settings", methods=["PATCH"])
|
||||||
|
@acl("system")
|
||||||
|
async def write_settings():
|
||||||
|
try:
|
||||||
|
UpdateSystemSettingsModel = UpdateSystemSettings.parse_obj(request.form_parsed)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
async with TinyDB(**TINYDB_PARAMS) as db:
|
||||||
|
db.table("system_settings").remove(doc_ids=[1])
|
||||||
|
db.table("system_settings").insert(
|
||||||
|
Document(UpdateSystemSettingsModel.dict(), doc_id=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Settings updated",
|
||||||
|
message="System settings were updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/settings")
|
||||||
|
@acl("system")
|
||||||
|
async def settings():
|
||||||
|
try:
|
||||||
|
settings = await components.system.get_system_settings()
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
return await render_template("system/settings.html", settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/logs")
|
||||||
|
@blueprint.route("/logs/search", methods=["POST"])
|
||||||
|
@acl("system")
|
||||||
|
async def cluster_logs():
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
search_model,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
sort_attr,
|
||||||
|
sort_reverse,
|
||||||
|
filters,
|
||||||
|
) = table_search_helper(
|
||||||
|
request.form_parsed,
|
||||||
|
"system_logs",
|
||||||
|
default_sort_attr="record.time.repr",
|
||||||
|
default_sort_reverse=True,
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
_logs = []
|
||||||
|
async with log_lock:
|
||||||
|
parser_failed = False
|
||||||
|
|
||||||
|
with fileinput.input(
|
||||||
|
components.system.list_application_log_files(), encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
for line in f:
|
||||||
|
if search_model.q in line:
|
||||||
|
try:
|
||||||
|
_logs.append(json.loads(line.strip()))
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
parser_failed = True
|
||||||
|
os.unlink(f.filename())
|
||||||
|
f.nextfile()
|
||||||
|
|
||||||
|
if parser_failed:
|
||||||
|
return trigger_notification(
|
||||||
|
level="user",
|
||||||
|
response_code=204,
|
||||||
|
title="Full refresh",
|
||||||
|
message="Logs rotated, requesting full refresh...",
|
||||||
|
additional_triggers={"forceRefresh": ""},
|
||||||
|
duration=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def system_logs_sort_func(sort_attr):
|
||||||
|
if sort_attr == "text":
|
||||||
|
return lambda d: (
|
||||||
|
d["text"],
|
||||||
|
datetime.fromisoformat(d["record"]["time"]["repr"]).timestamp(),
|
||||||
|
)
|
||||||
|
elif sort_attr == "record.level.no":
|
||||||
|
return lambda d: (
|
||||||
|
d["record"]["level"]["no"],
|
||||||
|
datetime.fromisoformat(d["record"]["time"]["repr"]).timestamp(),
|
||||||
|
)
|
||||||
|
else: # fallback to "record.time.repr"
|
||||||
|
return lambda d: datetime.fromisoformat(
|
||||||
|
d["record"]["time"]["repr"]
|
||||||
|
).timestamp()
|
||||||
|
|
||||||
|
log_pages = [
|
||||||
|
m
|
||||||
|
for m in batch(
|
||||||
|
sorted(
|
||||||
|
_logs,
|
||||||
|
key=system_logs_sort_func(sort_attr),
|
||||||
|
reverse=sort_reverse,
|
||||||
|
),
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_pages[page - 1]
|
||||||
|
except IndexError:
|
||||||
|
page = len(log_pages)
|
||||||
|
|
||||||
|
system_logs = log_pages[page - 1] if page else log_pages
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"system/includes/logs/table_body.html",
|
||||||
|
data={
|
||||||
|
"logs": system_logs,
|
||||||
|
"page_size": page_size,
|
||||||
|
"page": page,
|
||||||
|
"pages": len(log_pages),
|
||||||
|
"elements": len(_logs),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await render_template("system/logs.html")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/logs/refresh")
|
||||||
|
@acl("system")
|
||||||
|
async def refresh_cluster_logs():
|
||||||
|
await ws_htmx(
|
||||||
|
session["login"],
|
||||||
|
"beforeend",
|
||||||
|
'<div class="loading-logs" hidden _="on load trigger '
|
||||||
|
+ "notification("
|
||||||
|
+ "title: 'Please wait', level: 'user', "
|
||||||
|
+ "message: 'Requesting logs, your view will be updated automatically.', duration: 10000)\">"
|
||||||
|
+ "</div>",
|
||||||
|
"/system/logs",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not IN_MEMORY_DB.get("application_logs_refresh") or request.args.get("force"):
|
||||||
|
IN_MEMORY_DB["application_logs_refresh"] = ntime_utc_now()
|
||||||
|
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB,
|
||||||
|
"application_logs_refresh",
|
||||||
|
defaults.CLUSTER_LOGS_REFRESH_AFTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with log_lock:
|
||||||
|
async with ClusterLock("files", current_app):
|
||||||
|
for peer in cluster.peers.get_established():
|
||||||
|
if not peer in IN_MEMORY_DB["APP_LOGS_FULL_PULL"]:
|
||||||
|
IN_MEMORY_DB["APP_LOGS_FULL_PULL"][peer] = True
|
||||||
|
current_app.add_background_task(
|
||||||
|
expire_key,
|
||||||
|
IN_MEMORY_DB["APP_LOGS_FULL_PULL"],
|
||||||
|
peer,
|
||||||
|
36000,
|
||||||
|
)
|
||||||
|
start = 0
|
||||||
|
else:
|
||||||
|
start = -1
|
||||||
|
file_path = f"peer_files/{peer}/logs/application.log"
|
||||||
|
if os.path.exists(file_path) and os.path.getsize(file_path) > (
|
||||||
|
5 * 1024 * 1024
|
||||||
|
):
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
await cluster.request_files("logs/application.log", peer, start, -1)
|
||||||
|
|
||||||
|
missing_peers = ", ".join(cluster.peers.get_offline_peers())
|
||||||
|
|
||||||
|
if missing_peers:
|
||||||
|
await ws_htmx(
|
||||||
|
session["login"],
|
||||||
|
"beforeend",
|
||||||
|
'<div hidden _="on load trigger '
|
||||||
|
+ "notification("
|
||||||
|
+ "title: 'Missing peers', level: 'warning', "
|
||||||
|
+ f"message: 'Some peers seem to be offline and were not pulled: {missing_peers}', duration: 3000)\">"
|
||||||
|
+ "</div>",
|
||||||
|
"/system/logs",
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_ago = round(ntime_utc_now() - IN_MEMORY_DB["application_logs_refresh"])
|
||||||
|
|
||||||
|
await ws_htmx(
|
||||||
|
session["login"],
|
||||||
|
"beforeend",
|
||||||
|
'<div hidden _="on load trigger logsReady on #system-logs-table-search '
|
||||||
|
+ f"then put {refresh_ago} into #system-logs-last-refresh "
|
||||||
|
+ f'then wait 500 ms then trigger removeNotification on .notification-user"></div>',
|
||||||
|
"/system/logs",
|
||||||
|
)
|
||||||
|
|
||||||
|
return "", 204
|
||||||
211
components/web/blueprints/users.py
Normal file
211
components/web/blueprints/users.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import components.users
|
||||||
|
from components.utils import batch, ensure_list
|
||||||
|
from components.database import IN_MEMORY_DB
|
||||||
|
from components.models.users import UserGroups
|
||||||
|
from components.web.utils import *
|
||||||
|
|
||||||
|
|
||||||
|
blueprint = Blueprint("users", __name__, url_prefix="/system/users")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.context_processor
|
||||||
|
def load_context():
|
||||||
|
from components.models.users import UserProfile
|
||||||
|
|
||||||
|
context = dict()
|
||||||
|
context["schemas"] = {"user_profile": UserProfile.model_json_schema()}
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/groups", methods=["PATCH"])
|
||||||
|
@acl("system")
|
||||||
|
async def user_group():
|
||||||
|
try:
|
||||||
|
request_data = UserGroups.parse_obj(request.form_parsed)
|
||||||
|
|
||||||
|
assigned_to = [
|
||||||
|
u
|
||||||
|
for u in await components.users.search(name="", join_credentials=False)
|
||||||
|
if request_data.name in u.groups
|
||||||
|
]
|
||||||
|
|
||||||
|
assign_to = []
|
||||||
|
for user_id in request_data.members:
|
||||||
|
assign_to.append(
|
||||||
|
await components.users.get(user_id=user_id, join_credentials=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
_all = assigned_to + assign_to
|
||||||
|
|
||||||
|
async with ClusterLock(["users", "credentials"], current_app):
|
||||||
|
for user in _all:
|
||||||
|
user_dict = user.model_dump(mode="json")
|
||||||
|
if request_data.name in user_dict["groups"]:
|
||||||
|
user_dict["groups"].remove(request_data.name)
|
||||||
|
|
||||||
|
if (
|
||||||
|
request_data.new_name not in user_dict["groups"]
|
||||||
|
and user in assign_to
|
||||||
|
):
|
||||||
|
user_dict["groups"].append(request_data.new_name)
|
||||||
|
|
||||||
|
await components.users.patch(user_id=user.id, data=user_dict)
|
||||||
|
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<user_id>")
|
||||||
|
@acl("system")
|
||||||
|
async def get_user(user_id: str):
|
||||||
|
try:
|
||||||
|
user = await components.users.get(user_id=user_id)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return await render_or_json(
|
||||||
|
"system/includes/users/row.html", request.headers, user=user.dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/")
|
||||||
|
@blueprint.route("/search", methods=["POST"])
|
||||||
|
@acl("system")
|
||||||
|
@formoptions(["users"])
|
||||||
|
async def get_users():
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
search_model,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
sort_attr,
|
||||||
|
sort_reverse,
|
||||||
|
filters,
|
||||||
|
) = table_search_helper(request.form_parsed, "users", default_sort_attr="login")
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
matched_users = [
|
||||||
|
m.dict() for m in await components.users.search(name=search_model.q)
|
||||||
|
]
|
||||||
|
|
||||||
|
user_pages = [
|
||||||
|
m
|
||||||
|
for m in batch(
|
||||||
|
sorted(
|
||||||
|
matched_users,
|
||||||
|
key=lambda x: x.get(sort_attr, "id"),
|
||||||
|
reverse=sort_reverse,
|
||||||
|
),
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_pages[page - 1]
|
||||||
|
except IndexError:
|
||||||
|
page = len(user_pages)
|
||||||
|
|
||||||
|
users = user_pages[page - 1] if page else user_pages
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"system/includes/users/table_body.html",
|
||||||
|
data={
|
||||||
|
"users": users,
|
||||||
|
"page_size": page_size,
|
||||||
|
"page": page,
|
||||||
|
"pages": len(user_pages),
|
||||||
|
"elements": len(matched_users),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await render_template("system/users.html", data={})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/delete", methods=["POST"])
|
||||||
|
@blueprint.route("/<user_id>", methods=["DELETE"])
|
||||||
|
@acl("system")
|
||||||
|
async def delete_user(user_id: str | None = None):
|
||||||
|
if request.method == "POST":
|
||||||
|
user_ids = request.form_parsed.get("id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ClusterLock(["users", "credentials"], current_app):
|
||||||
|
for user_id in ensure_list(user_ids):
|
||||||
|
await components.users.delete(user_id=user_id)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="User removed",
|
||||||
|
message=f"{len(ensure_list(user_ids))} user{'s' if len(ensure_list(user_ids)) > 1 else ''} removed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<user_id>/credential/<hex_id>", methods=["PATCH"])
|
||||||
|
@acl("system")
|
||||||
|
async def patch_user_credential(user_id: str, hex_id: str):
|
||||||
|
try:
|
||||||
|
async with ClusterLock("credentials", current_app):
|
||||||
|
await components.users.patch_credential(
|
||||||
|
user_id=user_id,
|
||||||
|
hex_id=hex_id,
|
||||||
|
data=request.form_parsed,
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="Credential modified",
|
||||||
|
message="Credential was modified",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/patch", methods=["POST"])
|
||||||
|
@blueprint.route("/<user_id>", methods=["PATCH"])
|
||||||
|
@acl("system")
|
||||||
|
async def patch_user(user_id: str | None = None):
|
||||||
|
try:
|
||||||
|
if not user_id:
|
||||||
|
user_id = request.form_parsed.get("id")
|
||||||
|
|
||||||
|
async with ClusterLock(["users", "credentials"], current_app):
|
||||||
|
await components.users.patch(user_id=user_id, data=request.form_parsed)
|
||||||
|
await components.users.patch_profile(
|
||||||
|
user_id=user_id, data=request.form_parsed.get("profile", {})
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return validation_error(e.errors())
|
||||||
|
except ValueError as e:
|
||||||
|
name, message = e.args
|
||||||
|
return validation_error([{"loc": [name], "msg": message}])
|
||||||
|
|
||||||
|
IN_MEMORY_DB["SESSION_VALIDATED"].pop(user_id, None)
|
||||||
|
|
||||||
|
return trigger_notification(
|
||||||
|
level="success",
|
||||||
|
response_code=204,
|
||||||
|
title="User modified",
|
||||||
|
message=f"User was updated",
|
||||||
|
)
|
||||||
1
components/web/static_files/.gitignore
vendored
Normal file
1
components/web/static_files/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
*.sh
|
||||||
4
components/web/static_files/css/.gitignore
vendored
Normal file
4
components/web/static_files/css/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
*
|
||||||
|
!pico-custom.scss
|
||||||
|
!pico-custom.css
|
||||||
|
!.gitignore
|
||||||
9363
components/web/static_files/css/pico-custom.css
Normal file
9363
components/web/static_files/css/pico-custom.css
Normal file
File diff suppressed because it is too large
Load Diff
485
components/web/static_files/css/pico-custom.scss
Normal file
485
components/web/static_files/css/pico-custom.scss
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
@use "pico" with (
|
||||||
|
$enable-semantic-container: true,
|
||||||
|
$enable-responsive-spacings: true,
|
||||||
|
$enable-responsive-typography: false,
|
||||||
|
$theme-color: "slate",
|
||||||
|
$breakpoints: (
|
||||||
|
sm: (breakpoint: 576px, viewport: 95%),
|
||||||
|
md: (breakpoint: 768px, viewport: 95%),
|
||||||
|
lg: (breakpoint: 1024px, viewport: 90%),
|
||||||
|
xl: (breakpoint: 1280px, viewport: 90%),
|
||||||
|
xxl: (breakpoint: 1536px, viewport: 85%)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@use "colors" as *;
|
||||||
|
@use "sass:map";
|
||||||
|
@use "settings" as *;
|
||||||
|
@use "sass:math";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: go;
|
||||||
|
src: local("Go Regular"), url(../fonts/Go-Regular.woff2) format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go;
|
||||||
|
src: local("Go Italic"), url(../fonts/Go-Italic.woff2) format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go medium;
|
||||||
|
src: local("Go Medium"), url(../fonts/Go-Medium.woff2) format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go medium;
|
||||||
|
src: local("Go Medium Italic"), url(../fonts/Go-Medium-Italic.woff2) format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go;
|
||||||
|
src: local("Go Bold"), url(../fonts/Go-Bold.woff2) format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go;
|
||||||
|
src: local("Go Bold Italic"), url(../fonts/Go-Bold-Italic.woff2) format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go smallcaps;
|
||||||
|
src: local("Go Smallcaps"), url(../fonts/Go-Smallcaps.woff2) format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go smallcaps;
|
||||||
|
src: local("Go Smallcaps Italic"), url(../fonts/Go-Smallcaps-Italic.woff2) format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go mono;
|
||||||
|
src: local("Go Mono"), url(../fonts/Go-Mono.woff2) format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go mono;
|
||||||
|
src: local("Go Mono Italic"), url(../fonts/Go-Mono-Italic.woff2) format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go mono;
|
||||||
|
src: local("Go Mono Bold"), url(../fonts/Go-Mono-Bold.woff2) format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: go mono;
|
||||||
|
src: local("Go Mono Bold Italic"), url(../fonts/Go-Mono-Bold-Italic.woff2) format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--pico-font-family-sans-serif: "Go", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, var(--pico-font-family-emoji);
|
||||||
|
--pico-form-element-spacing-vertical: .5rem;
|
||||||
|
--pico-form-element-spacing-horizontal: .5rem;
|
||||||
|
--pico-border-radius: 0;
|
||||||
|
--pico-font-size: 12pt;
|
||||||
|
--pico-line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
margin-block: calc(var(--pico-spacing)/4);
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: calc(var(--pico-spacing)/2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-theme-toggle {
|
||||||
|
cursor:pointer !important;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
.hi, .hi a {
|
||||||
|
font-size:1.1rem;
|
||||||
|
--pico-text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td article {
|
||||||
|
margin-bottom: var(--pico-spacing);
|
||||||
|
}
|
||||||
|
table tr {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table td.created-modified {
|
||||||
|
background-image: var(--pico-icon-date);
|
||||||
|
background-position: center right var(--pico-form-element-spacing-vertical);
|
||||||
|
background-size: 1rem;
|
||||||
|
padding-inline-end: 2rem;
|
||||||
|
}
|
||||||
|
table td.created-modified, table th.created-modified {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-text-decoration {
|
||||||
|
text-decoration: none !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-loading] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
cursor:help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[contenteditable] {
|
||||||
|
display: inline-block;
|
||||||
|
border-bottom: 1px dotted #{$slate-300};
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
[contenteditable]:focus {
|
||||||
|
padding: calc(var(--pico-spacing)/2);
|
||||||
|
background: var(--pico-contrast);
|
||||||
|
color: var(--pico-contrast-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="group"] {
|
||||||
|
--pico-group-box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Table navigation
|
||||||
|
*/
|
||||||
|
.table-navigation {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.table-navigation .paging:not(.disabled) {
|
||||||
|
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
|
||||||
|
color: var(--pico-color);
|
||||||
|
}
|
||||||
|
.table-navigation button.sorting {
|
||||||
|
font-size: .875rem;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.table-navigation div.filters {
|
||||||
|
white-space: wrap;
|
||||||
|
}
|
||||||
|
.table-navigation div.filter-buttons button {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: calc(var(--pico-form-element-spacing-vertical) / 2) calc(var(--pico-form-element-spacing-horizontal) / 2);
|
||||||
|
border-color: var(--pico-form-element-border-color);
|
||||||
|
--pico-border-radius: .5rem;
|
||||||
|
}
|
||||||
|
.table-navigation .paging.disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
# Notifications
|
||||||
|
*/
|
||||||
|
#notification-container {
|
||||||
|
position: fixed;
|
||||||
|
left: .25rem;
|
||||||
|
bottom: .25rem;
|
||||||
|
z-index: 999;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.notification:not(:last-child) {
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.notification-text {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.notification {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--pico-form-element-spacing-vertical) 2rem;
|
||||||
|
color: var(--pico-color);
|
||||||
|
background-color: var(--pico-background-color);
|
||||||
|
background-image: var(--pico-icon);
|
||||||
|
background-position: center left var(--pico-form-element-spacing-vertical);
|
||||||
|
background-size: calc(var(--pico-spacing) * 1.5);
|
||||||
|
background-blend-mode: color-burn;
|
||||||
|
padding-left: calc(var(--pico-form-element-spacing-vertical) * 2 + calc(var(--pico-spacing) * 1.5));
|
||||||
|
}
|
||||||
|
.notification-error {
|
||||||
|
--pico-background-color: #{$red-600};
|
||||||
|
--pico-icon: var(--pico-icon-invalid);
|
||||||
|
--pico-color: #{$red-50};
|
||||||
|
}
|
||||||
|
.notification-warning {
|
||||||
|
--pico-background-color: #{$yellow-50};
|
||||||
|
--pico-icon: var(--pico-icon-invalid);
|
||||||
|
--pico-color: #{$yellow-900};
|
||||||
|
}
|
||||||
|
.notification-success {
|
||||||
|
--pico-background-color: #{$green-550};
|
||||||
|
--pico-icon: var(--pico-icon-valid);
|
||||||
|
--pico-color: #{$slate-50};
|
||||||
|
}
|
||||||
|
.notification-user {
|
||||||
|
--pico-background-color: #{$azure-450};
|
||||||
|
--pico-icon: var(--pico-icon-chevron);
|
||||||
|
--pico-color: #{$slate-50};
|
||||||
|
}
|
||||||
|
.notification-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.notification-system > .notification-title:before {
|
||||||
|
content:"\1F4E2\0020 Broadcast:\0020";
|
||||||
|
}
|
||||||
|
.notification-system {
|
||||||
|
--pico-background-color: #{$fuchsia-800};
|
||||||
|
--pico-icon: var(--pico-icon-chevron);
|
||||||
|
--pico-color: #{$fuchsia-100};
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20% 60% 20%;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-register { grid-column-start: 2; }
|
||||||
|
|
||||||
|
|
||||||
|
thead th, thead td, tfoot th, tfoot td {
|
||||||
|
--pico-font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, a { touch-action: manipulation; }
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog article {
|
||||||
|
max-width: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-text-wrap {
|
||||||
|
text-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.text-wrap {
|
||||||
|
text-wrap: balance;
|
||||||
|
white-space: break-spaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-grid.grid > article:first-child {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
.split-grid.grid > article {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-end {
|
||||||
|
display: grid;
|
||||||
|
justify-content: end;
|
||||||
|
grid-auto-columns: max-content;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
gap: calc(var(--pico-spacing)/2);
|
||||||
|
align-items: baseline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.grid-space-between {
|
||||||
|
display: grid;
|
||||||
|
justify-content: space-between;
|
||||||
|
grid-auto-columns: max-content;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
gap: calc(var(--pico-spacing) /2);
|
||||||
|
white-space: nowrap;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: calc(var(--pico-spacing) /2) auto;
|
||||||
|
}
|
||||||
|
.grid-3-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: calc(var(--pico-spacing) /2);
|
||||||
|
}
|
||||||
|
.grid-3-cols .span-3 {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
.grid-3-cols > article {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: var(--pico-form-element-spacing-vertical);
|
||||||
|
--pico-border-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-select {
|
||||||
|
background-color: rgb(128 128 128 / 10%);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
position: sticky;
|
||||||
|
bottom: var(--pico-form-element-spacing-vertical);
|
||||||
|
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav details.dropdown {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.vault-unlock {
|
||||||
|
padding: var(--pico-spacing) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
#notification-container {
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
dialog article {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
.grid-3-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
gap: calc(var(--pico-spacing)/2);
|
||||||
|
}
|
||||||
|
.grid-3-cols .span-3 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
.grid-3-cols > article {
|
||||||
|
margin-bottom: 0;
|
||||||
|
--pico-border-radius: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.split-grid.grid {
|
||||||
|
gap: calc(var(--pico-spacing) /2);
|
||||||
|
}
|
||||||
|
.split-grid.grid > article {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
--pico-spacing: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.dns-data {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.system-field label:before {
|
||||||
|
content:"\1f512\0020";
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.keypair {
|
||||||
|
border: 1px solid var(--pico-form-element-border-color);
|
||||||
|
padding: var(--pico-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
///////////////////////////////////////
|
||||||
|
// Generators for colors and breakpoints
|
||||||
|
///////////////////////////////////////
|
||||||
|
|
||||||
|
@function get-luminance($color) {
|
||||||
|
$red: math.div(red($color), 255);
|
||||||
|
$green: math.div(green($color), 255);
|
||||||
|
$blue: math.div(blue($color), 255);
|
||||||
|
|
||||||
|
@return ($red * 0.2126) + ($green * 0.7152) + ($blue * 0.0722);
|
||||||
|
}
|
||||||
|
|
||||||
|
@function get-contrast-ratio($color1, $color2) {
|
||||||
|
$l1: get-luminance($color1);
|
||||||
|
$l2: get-luminance($color2);
|
||||||
|
|
||||||
|
@if $l1 > $l2 {
|
||||||
|
@return math.div($l1, $l2);
|
||||||
|
} @else {
|
||||||
|
@return math.div($l2, $l1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@function get-contrast-color($color) {
|
||||||
|
$dark-color: $grey-900;
|
||||||
|
$light-color: $slate-100;
|
||||||
|
$contrast-with-dark: get-contrast-ratio($color, $dark-color);
|
||||||
|
$contrast-with-light: get-contrast-ratio($color, $light-color);
|
||||||
|
|
||||||
|
@if $contrast-with-light >= 2.0 {
|
||||||
|
@return $light-color;
|
||||||
|
} @else {
|
||||||
|
@return $dark-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@each $color-key, $color-var in $colors {
|
||||||
|
@each $shade, $value in $color-var {
|
||||||
|
.color-#{"#{$color-key}"}-#{$shade} {
|
||||||
|
color: $value !important;
|
||||||
|
}
|
||||||
|
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{"#{$color-key}"}-#{$shade},
|
||||||
|
[type="reset"].button-#{"#{$color-key}"}-#{$shade} {
|
||||||
|
color: get-contrast-color($value);
|
||||||
|
border-color: $value;
|
||||||
|
background-color: $value;
|
||||||
|
}
|
||||||
|
:is(a).color-#{"#{$color-key}"}-#{$shade} {
|
||||||
|
text-decoration-color: $value !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if map-has-key($color-var, 500) {
|
||||||
|
.color-#{"#{$color-key}"} {
|
||||||
|
@extend .color-#{"#{$color-key}"}-500;
|
||||||
|
}
|
||||||
|
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{"#{$color-key}"},
|
||||||
|
[type="reset"].button-#{"#{$color-key}"} {
|
||||||
|
@extend .button-#{"#{$color-key}"}-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@each $size, $data in $breakpoints {
|
||||||
|
$breakpoint: map-get($data, breakpoint);
|
||||||
|
@media (max-width: $breakpoint) {
|
||||||
|
.hide-below-#{$size} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.show-below-#{$size} {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: $breakpoint + 1px) {
|
||||||
|
.show-below-#{$size} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
components/web/static_files/empty.html
Normal file
0
components/web/static_files/empty.html
Normal file
BIN
components/web/static_files/fonts/Go-Bold-Italic.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Bold-Italic.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Bold.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Bold.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Italic.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Italic.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Medium-Italic.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Medium-Italic.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Medium.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Medium.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Mono-Bold-Italic.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Mono-Bold-Italic.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Mono-Bold.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Mono-Bold.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Mono-Italic.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Mono-Italic.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Mono.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Mono.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Regular.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Regular.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Smallcaps-Italic.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Smallcaps-Italic.woff2
Normal file
Binary file not shown.
BIN
components/web/static_files/fonts/Go-Smallcaps.woff2
Normal file
BIN
components/web/static_files/fonts/Go-Smallcaps.woff2
Normal file
Binary file not shown.
36
components/web/static_files/fonts/LICENSE.txt
Normal file
36
components/web/static_files/fonts/LICENSE.txt
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
These fonts were created by the Bigelow & Holmes foundry specifically for the
|
||||||
|
Go project. See https://blog.golang.org/go-fonts for details.
|
||||||
|
|
||||||
|
They are licensed under the same open source license as the rest of the Go
|
||||||
|
project's software:
|
||||||
|
|
||||||
|
Copyright (c) 2016 Bigelow & Holmes Inc.. All rights reserved.
|
||||||
|
|
||||||
|
Distribution of this font is governed by the following license. If you do not
|
||||||
|
agree to this license, including the disclaimer, do not distribute or modify
|
||||||
|
this font.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||||
|
used to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
DISCLAIMER: THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
272
components/web/static_files/hyperscript/common._hs
Normal file
272
components/web/static_files/hyperscript/common._hs
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
def postJson(url, dataObject)
|
||||||
|
fetch `${url}` with method:'POST', body:dataObject as JSON, headers:{content-type:'application/json'}
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
def getJsonUrlAsObject(url)
|
||||||
|
fetch `${url}` with method:'GET', headers:{content-type:'application/json'}
|
||||||
|
return result as Object
|
||||||
|
end
|
||||||
|
|
||||||
|
def countdownSeconds(el, sec)
|
||||||
|
set i to sec
|
||||||
|
set _s to el's textContent
|
||||||
|
repeat until i is 0
|
||||||
|
put `${_s} (${i}s)` into el
|
||||||
|
decrement i by 1
|
||||||
|
wait 1s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def setCheckboxes(el, option)
|
||||||
|
repeat for e in (<input[type='checkbox'].multiselect/> in el)
|
||||||
|
if option == 'all'
|
||||||
|
set e's @checked to 'true'
|
||||||
|
set e.checked to true
|
||||||
|
else if option == 'none'
|
||||||
|
remove @checked from e
|
||||||
|
set e.checked to false
|
||||||
|
else if option == 'invert'
|
||||||
|
if e.checked
|
||||||
|
toggle [@checked='false'] on e
|
||||||
|
set e.checked to false
|
||||||
|
else
|
||||||
|
toggle [@checked='true'] on e
|
||||||
|
set e.checked to true
|
||||||
|
end
|
||||||
|
else if option == 'toggle'
|
||||||
|
toggle [@checked='true'] on e
|
||||||
|
if e's @checked set e.checked to true else set e.checked to false end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior trCheckboxSelect
|
||||||
|
on click
|
||||||
|
document.getSelection().removeAllRanges()
|
||||||
|
if not event.shiftKey
|
||||||
|
take .select-tr-element from <tr/> in closest <table/> for me
|
||||||
|
call setCheckboxes(me, 'toggle') unless event.target.tagName.toLowerCase() === 'a'
|
||||||
|
else
|
||||||
|
document.getSelection().removeAllRanges()
|
||||||
|
get first .select-tr-element in closest <table/>
|
||||||
|
if it
|
||||||
|
set toggleTo to 'none'
|
||||||
|
if checked of first .multiselect in it
|
||||||
|
set toggleTo to 'all'
|
||||||
|
end
|
||||||
|
set selectedTrElement to it
|
||||||
|
if it.rowIndex < my.rowIndex
|
||||||
|
repeat while selectedTrElement.nextElementSibling
|
||||||
|
set selectedTrElement to selectedTrElement.nextElementSibling
|
||||||
|
call setCheckboxes(selectedTrElement, toggleTo)
|
||||||
|
if selectedTrElement is me
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
repeat while selectedTrElement.previousElementSibling
|
||||||
|
call setCheckboxes(selectedTrElement, toggleTo)
|
||||||
|
if selectedTrElement is me
|
||||||
|
break
|
||||||
|
end
|
||||||
|
set selectedTrElement to selectedTrElement.previousElementSibling
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior buttonCheckHtmxResponse
|
||||||
|
on htmx:afterRequest from closest <form/> to me
|
||||||
|
if (closest <form/> to me) != (event.target) exit end
|
||||||
|
set :_v to my textContent unless :_v
|
||||||
|
if event.detail.successful then
|
||||||
|
put `👍` into me
|
||||||
|
else
|
||||||
|
put `🤖 An error occured` into me
|
||||||
|
end
|
||||||
|
wait 1s
|
||||||
|
put :_v into me
|
||||||
|
set :_v to null
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior confirmButton
|
||||||
|
init set :inner to my.innerHTML end
|
||||||
|
on every click
|
||||||
|
halt the event
|
||||||
|
end
|
||||||
|
on click[event.detail==1] from me queue none
|
||||||
|
set x to 3
|
||||||
|
repeat until x == 0
|
||||||
|
put `Confirm ${x}x` into me
|
||||||
|
wait for a click or 1500ms
|
||||||
|
if the result's type is 'click'
|
||||||
|
decrement x
|
||||||
|
else
|
||||||
|
put :inner into me
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
put :inner into me
|
||||||
|
trigger confirmedButton
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior inlineHtmxRename
|
||||||
|
init
|
||||||
|
set :_textContent to my.textContent
|
||||||
|
end
|
||||||
|
|
||||||
|
on click halt the event end
|
||||||
|
|
||||||
|
on htmx:afterRequest
|
||||||
|
if event.detail.successful == true
|
||||||
|
set :_textContent to my.textContent
|
||||||
|
end
|
||||||
|
set my.textContent to :_textContent
|
||||||
|
end
|
||||||
|
|
||||||
|
on htmx:confirm(issueRequest)
|
||||||
|
halt the event
|
||||||
|
call confirm(`${:_textContent} to ${my.textContent}?`)
|
||||||
|
if not result set my.textContent to :_textContent else issueRequest() end
|
||||||
|
end
|
||||||
|
|
||||||
|
on blur
|
||||||
|
if my.textContent == '' set my.textContent to :_textContent then exit end
|
||||||
|
if my.textContent == :_textContent exit end
|
||||||
|
set @hx-vals to `{"${my @data-patch-parameter}": "${my.textContent}"}`
|
||||||
|
trigger editContent on me
|
||||||
|
end
|
||||||
|
|
||||||
|
on keydown[keyCode == 13]
|
||||||
|
me.blur()
|
||||||
|
halt the event
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior tresorToggle
|
||||||
|
def setUnlocked
|
||||||
|
get #vault-unlock-pin
|
||||||
|
add @disabled to it
|
||||||
|
set its @placeholder to 'Tresor is unlocked'
|
||||||
|
set #vault-unlock's textContent to '🔓'
|
||||||
|
end
|
||||||
|
def setLocked
|
||||||
|
get #vault-unlock-pin
|
||||||
|
remove @disabled from it
|
||||||
|
set its @placeholder to 'Tresor password'
|
||||||
|
set #vault-unlock's textContent to '🔐'
|
||||||
|
end
|
||||||
|
def noTresor
|
||||||
|
get #vault-unlock-pin
|
||||||
|
add @disabled to it
|
||||||
|
set its @placeholder to 'No tresor available'
|
||||||
|
set #vault-unlock's textContent to '⛔'
|
||||||
|
end
|
||||||
|
init
|
||||||
|
if window.vault.isUnlocked()
|
||||||
|
call setUnlocked()
|
||||||
|
else
|
||||||
|
if #vault-unlock's @data-tresor != ""
|
||||||
|
call setLocked()
|
||||||
|
else
|
||||||
|
call noTresor()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
on keydown[keyCode == 13] from #vault-unlock-pin
|
||||||
|
trigger click on #vault-unlock unless #vault-unlock-pin's value is empty
|
||||||
|
end
|
||||||
|
on click from #vault-unlock
|
||||||
|
halt the event
|
||||||
|
if not window.vault.isUnlocked()
|
||||||
|
exit unless value of #vault-unlock-pin
|
||||||
|
call JSON.parse(#vault-unlock's @data-tresor) set keyData to the result
|
||||||
|
call VaultUnlockPrivateKey(value of #vault-unlock-pin, keyData)
|
||||||
|
call setUnlocked()
|
||||||
|
else
|
||||||
|
call window.vault.lock()
|
||||||
|
call setLocked()
|
||||||
|
end
|
||||||
|
set value of #vault-unlock-pin to ''
|
||||||
|
on exception(error)
|
||||||
|
trigger notification(
|
||||||
|
title: 'Tresor error',
|
||||||
|
level: 'validationError',
|
||||||
|
message: 'Could not unlock tresor, check your PIN',
|
||||||
|
duration: 3000,
|
||||||
|
locations: ['vault-unlock-pin']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior bodydefault
|
||||||
|
on htmx:wsError or htmx:wsClose
|
||||||
|
set #ws-indicator's textContent to '⭕'
|
||||||
|
end
|
||||||
|
|
||||||
|
on keydown
|
||||||
|
exit unless window.vault.isUnlocked()
|
||||||
|
if navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||||
|
set ctrlOrCmd to event.metaKey
|
||||||
|
else
|
||||||
|
set ctrlOrCmd to event.ctrlKey
|
||||||
|
end
|
||||||
|
if (event.key is "F5" or (ctrlOrCmd and event.key.toLowerCase() === "r")) or ((ctrlOrCmd and event.shiftKey and event.key.toLowerCase() === "r") or (event.shiftKey and e.key === "F5"))
|
||||||
|
trigger notification(
|
||||||
|
title: 'Unlocked session',
|
||||||
|
level: 'user',
|
||||||
|
message: 'Preventing window reload due to unlocked session',
|
||||||
|
duration: 2000,
|
||||||
|
locations: []
|
||||||
|
)
|
||||||
|
halt the event
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on htmx:responseError
|
||||||
|
set status to event.detail.xhr.status
|
||||||
|
if status >= 500
|
||||||
|
trigger notification(title: 'Server error', level: 'error', message: 'The server could not handle the given request', duration: 10000)
|
||||||
|
else if status == 404
|
||||||
|
trigger notification(title: 'Not found', level: 'error', message: `Route not found: ${event.detail.xhr.responseURL}`, duration: 3000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on htmx:configRequest
|
||||||
|
if window.vault.isUnlocked()
|
||||||
|
repeat for p in event.detail.parameters
|
||||||
|
log p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on htmx:beforeRequest
|
||||||
|
remove @aria-invalid from <[aria-invalid]/>
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
behavior objectFilters(submitForm)
|
||||||
|
on click from <button[name=_filters]/> in me
|
||||||
|
halt the event
|
||||||
|
put '' into .generated-filters in me
|
||||||
|
|
||||||
|
repeat for btn in (<button[name=_filters]/> in me)
|
||||||
|
if (btn is event.target and btn does not match .active) or (btn is not event.target and btn matches .active)
|
||||||
|
render #filter-item with (value: btn's value)
|
||||||
|
then put the result at the end of .generated-filters in me
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if length of <.generated-filters input/> is 0
|
||||||
|
render #filter-item with (value: '')
|
||||||
|
then put the result at the end of .generated-filters in me
|
||||||
|
end
|
||||||
|
|
||||||
|
trigger submit on submitForm unless submitForm == ''
|
||||||
|
end
|
||||||
|
end
|
||||||
7723
components/web/static_files/js/_hyperscript/_hyperscript.js
Normal file
7723
components/web/static_files/js/_hyperscript/_hyperscript.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,86 @@
|
|||||||
|
(function (self, factory) {
|
||||||
|
const plugin = factory(self)
|
||||||
|
|
||||||
|
if (typeof exports === 'object' && typeof exports['nodeName'] !== 'string') {
|
||||||
|
module.exports = plugin
|
||||||
|
} else {
|
||||||
|
if ('_hyperscript' in self) self._hyperscript.use(plugin)
|
||||||
|
}
|
||||||
|
})(typeof self !== 'undefined' ? self : this, self => {
|
||||||
|
|
||||||
|
function compileTemplate(template) {
|
||||||
|
return template.replace(/(?:^|\n)([^@]*)@?/gm, function (match, p1) {
|
||||||
|
var templateStr = (" " + p1).replace(/([^\\])\$\{/g, "$1$${escape html ").substring(1);
|
||||||
|
return "\ncall meta.__ht_template_result.push(`" + templateStr + "`)\n";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HyperscriptObject} _hyperscript
|
||||||
|
*/
|
||||||
|
return _hyperscript => {
|
||||||
|
|
||||||
|
function renderTemplate(template, ctx) {
|
||||||
|
var buf = [];
|
||||||
|
const renderCtx = Object.assign({}, ctx);
|
||||||
|
renderCtx.meta = Object.assign({ __ht_template_result: buf }, ctx.meta);
|
||||||
|
_hyperscript.evaluate(template, renderCtx);
|
||||||
|
return buf.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
_hyperscript.addCommand("render", function (parser, runtime, tokens) {
|
||||||
|
if (!tokens.matchToken("render")) return;
|
||||||
|
var template_ = parser.requireElement("expression", tokens);
|
||||||
|
var templateArgs = {};
|
||||||
|
if (tokens.matchToken("with")) {
|
||||||
|
templateArgs = parser.parseElement("namedArgumentList", tokens);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
args: [template_, templateArgs],
|
||||||
|
op: function (ctx, template, templateArgs) {
|
||||||
|
if (!(template instanceof Element)) throw new Error(template_.sourceFor() + " is not an element");
|
||||||
|
const context = _hyperscript.internals.runtime.makeContext()
|
||||||
|
context.locals = templateArgs;
|
||||||
|
ctx.result = renderTemplate(compileTemplate(template.innerHTML), context);
|
||||||
|
return runtime.findNext(this, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHTML(html) {
|
||||||
|
return String(html)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/\x22/g, """)
|
||||||
|
.replace(/\x27/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
_hyperscript.addLeafExpression("escape", function (parser, runtime, tokens) {
|
||||||
|
if (!tokens.matchToken("escape")) return;
|
||||||
|
var escapeType = tokens.matchTokenType("IDENTIFIER").value;
|
||||||
|
|
||||||
|
// hidden! for use in templates
|
||||||
|
var unescaped = tokens.matchToken("unescaped");
|
||||||
|
|
||||||
|
var arg = parser.requireElement("expression", tokens);
|
||||||
|
|
||||||
|
return {
|
||||||
|
args: [arg],
|
||||||
|
op: function (ctx, arg) {
|
||||||
|
if (unescaped) return arg;
|
||||||
|
if (arg === undefined) return "";
|
||||||
|
switch (escapeType) {
|
||||||
|
case "html":
|
||||||
|
return escapeHTML(arg);
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown escape: " + escapeType);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evaluate: function (ctx) {
|
||||||
|
return runtime.unifiedEval(this, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
5261
components/web/static_files/js/htmx.org/htmx.org.js
Normal file
5261
components/web/static_files/js/htmx.org/htmx.org.js
Normal file
File diff suppressed because it is too large
Load Diff
35
components/web/static_files/js/htmx.org/htmx.org.json-enc.js
Normal file
35
components/web/static_files/js/htmx.org/htmx.org.json-enc.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
(function() {
|
||||||
|
let api
|
||||||
|
htmx.defineExtension('json-enc', {
|
||||||
|
init: function(apiRef) {
|
||||||
|
api = apiRef
|
||||||
|
},
|
||||||
|
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
if (name === 'htmx:configRequest') {
|
||||||
|
evt.detail.headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
encodeParameters: function(xhr, parameters, elt) {
|
||||||
|
xhr.overrideMimeType('text/json')
|
||||||
|
|
||||||
|
const vals = api.getExpressionVars(elt)
|
||||||
|
const object = {}
|
||||||
|
parameters.forEach(function(value, key) {
|
||||||
|
// FormData encodes values as strings, restore hx-vals/hx-vars with their initial types
|
||||||
|
const typedValue = Object.hasOwn(vals, key) ? vals[key] : value
|
||||||
|
if (Object.hasOwn(object, key)) {
|
||||||
|
if (!Array.isArray(object[key])) {
|
||||||
|
object[key] = [object[key]]
|
||||||
|
}
|
||||||
|
object[key].push(typedValue)
|
||||||
|
} else {
|
||||||
|
object[key] = typedValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (JSON.stringify(object))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
@ -0,0 +1 @@
|
|||||||
|
(function(){const f=[];function c(t){return htmx.closest(t,"[data-loading-states]")||document.body}function u(t,n){if(document.body.contains(t)){n()}}function o(t,n){const a=htmx.closest(t,"[data-loading-path]");if(!a){return true}return a.getAttribute("data-loading-path")===n}function e(t,n,a,o){const i=htmx.closest(t,"[data-loading-delay]");if(i){const c=i.getAttribute("data-loading-delay")||200;const e=setTimeout(function(){a();f.push(function(){u(n,o)})},c);f.push(function(){u(n,function(){clearTimeout(e)})})}else{a();f.push(function(){u(n,o)})}}function s(t,n,a){return Array.from(htmx.findAll(t,"["+n+"]")).filter(function(t){return o(t,a)})}function r(t){if(t.getAttribute("data-loading-target")){return Array.from(htmx.findAll(t.getAttribute("data-loading-target")))}return[t]}htmx.defineExtension("loading-states",{onEvent:function(t,n){if(t==="htmx:beforeRequest"){const a=c(n.target);const o=["data-loading","data-loading-class","data-loading-class-remove","data-loading-disable","data-loading-aria-busy"];const i={};o.forEach(function(t){i[t]=s(a,t,n.detail.pathInfo.requestPath)});i["data-loading"].forEach(function(n){r(n).forEach(function(t){e(n,t,function(){t.style.display=n.getAttribute("data-loading")||"inline-block"},function(){t.style.display="none"})})});i["data-loading-class"].forEach(function(t){const a=t.getAttribute("data-loading-class").split(" ");r(t).forEach(function(n){e(t,n,function(){a.forEach(function(t){n.classList.add(t)})},function(){a.forEach(function(t){n.classList.remove(t)})})})});i["data-loading-class-remove"].forEach(function(t){const a=t.getAttribute("data-loading-class-remove").split(" ");r(t).forEach(function(n){e(t,n,function(){a.forEach(function(t){n.classList.remove(t)})},function(){a.forEach(function(t){n.classList.add(t)})})})});i["data-loading-disable"].forEach(function(n){r(n).forEach(function(t){e(n,t,function(){t.disabled=true},function(){t.disabled=false})})});i["data-loading-aria-busy"].forEach(function(n){r(n).forEach(function(t){e(n,t,function(){t.setAttribute("aria-busy","true")},function(){t.removeAttribute("aria-busy")})})})}if(t==="htmx:beforeOnLoad"){while(f.length>0){f.shift()()}}}})})();
|
||||||
471
components/web/static_files/js/htmx.org/htmx.org.ws.js
Normal file
471
components/web/static_files/js/htmx.org/htmx.org.ws.js
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
/*
|
||||||
|
WebSockets Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
|
htmx.defineExtension('ws', {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* init is called once, when this extension is first registered.
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// Store reference to internal API
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
|
// Default function for creating new EventSource objects
|
||||||
|
if (!htmx.createWebSocket) {
|
||||||
|
htmx.createWebSocket = createWebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default setting for reconnect delay
|
||||||
|
if (!htmx.config.wsReconnectDelay) {
|
||||||
|
htmx.config.wsReconnectDelay = 'full-jitter'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
*/
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
// Try to close the socket when elements are removed
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
|
||||||
|
if (internalData.webSocket) {
|
||||||
|
internalData.webSocket.close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
// Try to create websockets when elements are processed
|
||||||
|
case 'htmx:beforeProcessNode':
|
||||||
|
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||||||
|
ensureWebSocket(child)
|
||||||
|
})
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||||||
|
ensureWebSocketSend(child)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function splitOnWhitespace(trigger) {
|
||||||
|
return trigger.trim().split(/\s+/)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyWebsocketURL(elt) {
|
||||||
|
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||||||
|
if (legacySSEValue) {
|
||||||
|
var values = splitOnWhitespace(legacySSEValue)
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
var value = values[i].split(/:(.+)/)
|
||||||
|
if (value[0] === 'connect') {
|
||||||
|
return value[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||||
|
* the element's "ws-connect" attribute.
|
||||||
|
* @param {HTMLElement} socketElt
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function ensureWebSocket(socketElt) {
|
||||||
|
// If the element containing the WebSocket connection no longer exists, then
|
||||||
|
// do not connect/reconnect the WebSocket.
|
||||||
|
if (!api.bodyContains(socketElt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source straight from the element's value
|
||||||
|
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||||||
|
|
||||||
|
if (wssSource == null || wssSource === '') {
|
||||||
|
var legacySource = getLegacyWebsocketURL(socketElt)
|
||||||
|
if (legacySource == null) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
wssSource = legacySource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee that the wssSource value is a fully qualified URL
|
||||||
|
if (wssSource.indexOf('/') === 0) {
|
||||||
|
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||||||
|
if (location.protocol === 'https:') {
|
||||||
|
wssSource = 'wss://' + base_part + wssSource
|
||||||
|
} else if (location.protocol === 'http:') {
|
||||||
|
wssSource = 'ws://' + base_part + wssSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||||||
|
return htmx.createWebSocket(wssSource)
|
||||||
|
})
|
||||||
|
|
||||||
|
socketWrapper.addEventListener('message', function(event) {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = event.data
|
||||||
|
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||||
|
message: response,
|
||||||
|
socketWrapper: socketWrapper.publicInterface
|
||||||
|
})) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.withExtensions(socketElt, function(extension) {
|
||||||
|
response = extension.transformResponse(response, null, socketElt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var settleInfo = api.makeSettleInfo(socketElt)
|
||||||
|
var fragment = api.makeFragment(response)
|
||||||
|
|
||||||
|
if (fragment.children.length) {
|
||||||
|
var children = Array.from(fragment.children)
|
||||||
|
for (var i = 0; i < children.length; i++) {
|
||||||
|
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.settleImmediately(settleInfo.tasks)
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Put the WebSocket into the HTML Element's custom data.
|
||||||
|
api.getInternalData(socketElt).webSocket = socketWrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} WebSocketWrapper
|
||||||
|
* @property {WebSocket} socket
|
||||||
|
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||||
|
* @property {number} retryCount
|
||||||
|
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||||
|
* @property {(message: string, sendElt: Element) => void} send
|
||||||
|
* @property {(event: string, handler: Function) => void} addEventListener
|
||||||
|
* @property {() => void} handleQueuedMessages
|
||||||
|
* @property {() => void} init
|
||||||
|
* @property {() => void} close
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param socketElt
|
||||||
|
* @param socketFunc
|
||||||
|
* @returns {WebSocketWrapper}
|
||||||
|
*/
|
||||||
|
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||||
|
var wrapper = {
|
||||||
|
socket: null,
|
||||||
|
messageQueue: [],
|
||||||
|
retryCount: 0,
|
||||||
|
|
||||||
|
/** @type {Object<string, Function[]>} */
|
||||||
|
events: {},
|
||||||
|
|
||||||
|
addEventListener: function(event, handler) {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.addEventListener(event, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.events[event]) {
|
||||||
|
this.events[event] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events[event].push(handler)
|
||||||
|
},
|
||||||
|
|
||||||
|
sendImmediately: function(message, sendElt) {
|
||||||
|
if (!this.socket) {
|
||||||
|
api.triggerErrorEvent()
|
||||||
|
}
|
||||||
|
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||||
|
message,
|
||||||
|
socketWrapper: this.publicInterface
|
||||||
|
})) {
|
||||||
|
this.socket.send(message)
|
||||||
|
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||||
|
message,
|
||||||
|
socketWrapper: this.publicInterface
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
send: function(message, sendElt) {
|
||||||
|
if (this.socket.readyState !== this.socket.OPEN) {
|
||||||
|
this.messageQueue.push({ message, sendElt })
|
||||||
|
} else {
|
||||||
|
this.sendImmediately(message, sendElt)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleQueuedMessages: function() {
|
||||||
|
while (this.messageQueue.length > 0) {
|
||||||
|
var queuedItem = this.messageQueue[0]
|
||||||
|
if (this.socket.readyState === this.socket.OPEN) {
|
||||||
|
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||||||
|
this.messageQueue.shift()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||||
|
// Close discarded socket
|
||||||
|
this.socket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new WebSocket and event handlers
|
||||||
|
/** @type {WebSocket} */
|
||||||
|
var socket = socketFunc()
|
||||||
|
|
||||||
|
// The event.type detail is added for interface conformance with the
|
||||||
|
// other two lifecycle events (open and close) so a single handler method
|
||||||
|
// can handle them polymorphically, if required.
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||||||
|
|
||||||
|
this.socket = socket
|
||||||
|
|
||||||
|
socket.onopen = function(e) {
|
||||||
|
wrapper.retryCount = 0
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||||||
|
wrapper.handleQueuedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = function(e) {
|
||||||
|
// If socket should not be connected, stop further attempts to establish connection
|
||||||
|
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||||
|
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||||
|
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||||||
|
setTimeout(function() {
|
||||||
|
wrapper.retryCount += 1
|
||||||
|
wrapper.init()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||||
|
// to determine whether closure has been valid or abnormal
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = function(e) {
|
||||||
|
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||||||
|
maybeCloseWebSocketSource(socketElt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = this.events
|
||||||
|
Object.keys(events).forEach(function(k) {
|
||||||
|
events[k].forEach(function(e) {
|
||||||
|
socket.addEventListener(k, e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
close: function() {
|
||||||
|
this.socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.init()
|
||||||
|
|
||||||
|
wrapper.publicInterface = {
|
||||||
|
send: wrapper.send.bind(wrapper),
|
||||||
|
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||||
|
queue: wrapper.messageQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocketSend attaches trigger handles to elements with
|
||||||
|
* "ws-send" attribute
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function ensureWebSocketSend(elt) {
|
||||||
|
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||||||
|
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||||
|
processWebSocketSend(webSocketParent, elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hasWebSocket function checks if a node has webSocket instance attached
|
||||||
|
* @param {HTMLElement} node
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasWebSocket(node) {
|
||||||
|
return api.getInternalData(node).webSocket != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* processWebSocketSend adds event listeners to the <form> element so that
|
||||||
|
* messages can be sent to the WebSocket server when the form is submitted.
|
||||||
|
* @param {HTMLElement} socketElt
|
||||||
|
* @param {HTMLElement} sendElt
|
||||||
|
*/
|
||||||
|
function processWebSocketSend(socketElt, sendElt) {
|
||||||
|
var nodeData = api.getInternalData(sendElt)
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {WebSocketWrapper} */
|
||||||
|
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||||||
|
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||||||
|
var results = api.getInputValues(sendElt, 'post')
|
||||||
|
var errors = results.errors
|
||||||
|
var rawParameters = Object.assign({}, results.values)
|
||||||
|
var expressionVars = api.getExpressionVars(sendElt)
|
||||||
|
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||||||
|
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||||||
|
|
||||||
|
var sendConfig = {
|
||||||
|
parameters: filteredParameters,
|
||||||
|
unfilteredParameters: allParameters,
|
||||||
|
headers,
|
||||||
|
errors,
|
||||||
|
|
||||||
|
triggeringEvent: evt,
|
||||||
|
messageBody: undefined,
|
||||||
|
socketWrapper: socketWrapper.publicInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = sendConfig.messageBody
|
||||||
|
if (body === undefined) {
|
||||||
|
var toSend = Object.assign({}, sendConfig.parameters)
|
||||||
|
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||||||
|
body = JSON.stringify(toSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
socketWrapper.send(body, elt)
|
||||||
|
|
||||||
|
if (evt && api.shouldCancel(evt, elt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||||
|
* @param {number} retryCount // The number of retries that have already taken place
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getWebSocketReconnectDelay(retryCount) {
|
||||||
|
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||||
|
var delay = htmx.config.wsReconnectDelay
|
||||||
|
if (typeof delay === 'function') {
|
||||||
|
return delay(retryCount)
|
||||||
|
}
|
||||||
|
if (delay === 'full-jitter') {
|
||||||
|
var exp = Math.min(retryCount, 6)
|
||||||
|
var maxDelay = 1000 * Math.pow(2, exp)
|
||||||
|
return maxDelay * Math.random()
|
||||||
|
}
|
||||||
|
|
||||||
|
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||||
|
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||||
|
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||||
|
* returns FALSE.
|
||||||
|
*
|
||||||
|
* @param {*} elt
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function maybeCloseWebSocketSource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var internalData = api.getInternalData(elt)
|
||||||
|
if (internalData.webSocket) {
|
||||||
|
internalData.webSocket.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createWebSocket is the default method for creating new WebSocket objects.
|
||||||
|
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns WebSocket
|
||||||
|
*/
|
||||||
|
function createWebSocket(url) {
|
||||||
|
var sock = new WebSocket(url, [])
|
||||||
|
sock.binaryType = htmx.config.wsBinaryType
|
||||||
|
return sock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} attributeName
|
||||||
|
*/
|
||||||
|
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||||
|
var result = []
|
||||||
|
|
||||||
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||||
|
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||||
|
result.push(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search all child nodes that match the requested attribute
|
||||||
|
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||||||
|
result.push(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {T[]} arr
|
||||||
|
* @param {(T) => void} func
|
||||||
|
*/
|
||||||
|
function forEach(arr, func) {
|
||||||
|
if (arr) {
|
||||||
|
for (var i = 0; i < arr.length; i++) {
|
||||||
|
func(arr[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
File diff suppressed because one or more lines are too long
61
components/web/static_files/js/site.js
Normal file
61
components/web/static_files/js/site.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
htmx.on("body", "startReg", async function(evt){
|
||||||
|
const { startRegistration } = SimpleWebAuthnBrowser
|
||||||
|
var reg_button = htmx.find("#register")
|
||||||
|
reg_button.textContent = "Processing..."
|
||||||
|
reg_button.disabled = true
|
||||||
|
reg_opts = JSON.parse(evt.detail.value)
|
||||||
|
try {
|
||||||
|
reg_response = await startRegistration(reg_opts)
|
||||||
|
reg_button.setAttribute("hx-ext", "json-enc")
|
||||||
|
reg_button.setAttribute("hx-vals", JSON.stringify(reg_response))
|
||||||
|
reg_button.setAttribute("hx-post", "/auth/register/webauthn")
|
||||||
|
htmx.process("#register")
|
||||||
|
htmx.trigger("#register", "click")
|
||||||
|
} catch (err) {
|
||||||
|
htmx.trigger("body", "notification", {
|
||||||
|
level: "error",
|
||||||
|
title: "WebAuthn Error",
|
||||||
|
message: err
|
||||||
|
})
|
||||||
|
htmx.trigger("body", "authRegFailed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
htmx.on("body", "regCompleted", async function(evt){
|
||||||
|
htmx.trigger(".login-register:not([hidden])", "click")
|
||||||
|
htmx.ajax("GET", "/", "#body-main")
|
||||||
|
})
|
||||||
|
|
||||||
|
htmx.on("body", "startAuth", async function(evt){
|
||||||
|
const { startAuthentication } = SimpleWebAuthnBrowser
|
||||||
|
var login_button = htmx.find("#authenticate")
|
||||||
|
login_button.textContent = "Processing..."
|
||||||
|
login_button.disabled = true
|
||||||
|
auth_opts = JSON.parse(evt.detail.value)
|
||||||
|
try {
|
||||||
|
auth_response = await startAuthentication(auth_opts)
|
||||||
|
login_button.setAttribute("hx-ext", "json-enc")
|
||||||
|
login_button.setAttribute("hx-vals", JSON.stringify(auth_response))
|
||||||
|
login_button.setAttribute("hx-post", "/auth/login/webauthn")
|
||||||
|
htmx.process("#authenticate")
|
||||||
|
htmx.trigger("#authenticate", "click")
|
||||||
|
} catch (err) {
|
||||||
|
htmx.trigger("body", "notification", {
|
||||||
|
level: "error",
|
||||||
|
title: "WebAuthn Error",
|
||||||
|
message: err
|
||||||
|
})
|
||||||
|
htmx.trigger("body", "authRegFailed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
htmx.on("body", "authRegFailed", async function(evt){
|
||||||
|
htmx.ajax("GET", "/", "#body-main")
|
||||||
|
})
|
||||||
|
|
||||||
|
function datetime_local(add_minutes) {
|
||||||
|
var now = new Date();
|
||||||
|
minutes = (now.getMinutes() + add_minutes)
|
||||||
|
now.setMinutes(minutes - now.getTimezoneOffset());
|
||||||
|
return now.toISOString().slice(0,16);
|
||||||
|
}
|
||||||
223
components/web/static_files/js/uservault.js
Normal file
223
components/web/static_files/js/uservault.js
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
class UserCryptoVault {
|
||||||
|
constructor() {
|
||||||
|
this.encoder = new TextEncoder();
|
||||||
|
this.decoder = new TextDecoder();
|
||||||
|
this.keyPair = null;
|
||||||
|
this.salt = null;
|
||||||
|
this.iv = null;
|
||||||
|
this.wrappedPrivateKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateKeyPair() {
|
||||||
|
this.keyPair = await crypto.subtle.generateKey(
|
||||||
|
{ name: "ECDH", namedCurve: "P-256" },
|
||||||
|
true,
|
||||||
|
["deriveKey", "deriveBits"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPublicKeyPEM() {
|
||||||
|
const spki = await crypto.subtle.exportKey("spki", this.keyPair.publicKey);
|
||||||
|
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
||||||
|
const lines = b64.match(/.{1,64}/g).join("\n");
|
||||||
|
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async wrapPrivateKeyWithPassword(password) {
|
||||||
|
this.salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
this.iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
this.encoder.encode(password),
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveKey"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const kek = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: this.salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateKeyRaw = await crypto.subtle.exportKey("pkcs8", this.keyPair.privateKey);
|
||||||
|
this.wrappedPrivateKey = await crypto.subtle.encrypt({ name: "AES-GCM", iv: this.iv }, kek, privateKeyRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockPrivateKey(wrappedPrivateKey, salt, iv, password, publicKeyPem) {
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
this.encoder.encode(password),
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveKey"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const kek = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawPrivateKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, kek, wrappedPrivateKey);
|
||||||
|
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
rawPrivateKey,
|
||||||
|
{ name: "ECDH", namedCurve: "P-256" },
|
||||||
|
true,
|
||||||
|
["deriveKey", "deriveBits"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stripped = publicKeyPem.replace(/-----.*?-----|\n/g, "");
|
||||||
|
const spkiBytes = Uint8Array.from(atob(stripped), c => c.charCodeAt(0));
|
||||||
|
const publicKey = await crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
spkiBytes.buffer,
|
||||||
|
{ name: "ECDH", namedCurve: "P-256" },
|
||||||
|
true,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
this.keyPair = { privateKey, publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptData(message) {
|
||||||
|
if (!this.keyPair?.publicKey || !this.keyPair?.privateKey) throw new Error("Vault not unlocked");
|
||||||
|
|
||||||
|
const ephemeral = await crypto.subtle.generateKey(
|
||||||
|
{ name: "ECDH", namedCurve: "P-256" },
|
||||||
|
true,
|
||||||
|
["deriveKey"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedKey = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "ECDH",
|
||||||
|
public: this.keyPair.publicKey,
|
||||||
|
},
|
||||||
|
ephemeral.privateKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv },
|
||||||
|
sharedKey,
|
||||||
|
this.encoder.encode(message)
|
||||||
|
));
|
||||||
|
|
||||||
|
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
||||||
|
|
||||||
|
// Combine [ephemeral | iv | ciphertext]
|
||||||
|
const combined = new Uint8Array(ephemeralRaw.length + iv.length + ciphertext.length);
|
||||||
|
combined.set(ephemeralRaw, 0);
|
||||||
|
combined.set(iv, ephemeralRaw.length);
|
||||||
|
combined.set(ciphertext, ephemeralRaw.length + iv.length);
|
||||||
|
|
||||||
|
return "uv:" + btoa(String.fromCharCode(...combined));
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptData(blobBase64) {
|
||||||
|
if (!this.keyPair?.privateKey) throw new Error("Vault not unlocked");
|
||||||
|
|
||||||
|
const combined = Uint8Array.from(atob(blobBase64.replace(/^uv:/, "")), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const ephemeralLength = 65; // uncompressed EC point for P-256
|
||||||
|
const ivLength = 12;
|
||||||
|
|
||||||
|
const ephemeralRaw = combined.slice(0, ephemeralLength);
|
||||||
|
const iv = combined.slice(ephemeralLength, ephemeralLength + ivLength);
|
||||||
|
const ciphertext = combined.slice(ephemeralLength + ivLength);
|
||||||
|
|
||||||
|
const ephemeralPubKey = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
ephemeralRaw.buffer,
|
||||||
|
{ name: "ECDH", namedCurve: "P-256" },
|
||||||
|
true,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedKey = await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: "ECDH",
|
||||||
|
public: ephemeralPubKey,
|
||||||
|
},
|
||||||
|
this.keyPair.privateKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, sharedKey, ciphertext);
|
||||||
|
return this.decoder.decode(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnlocked() {
|
||||||
|
return !!this.keyPair?.privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock() {
|
||||||
|
this.keyPair = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(oldPassword, newPassword, wrappedPrivateKey, salt, iv, publicKeyPem) {
|
||||||
|
await this.unlockPrivateKey(wrappedPrivateKey, salt, iv, oldPassword, publicKeyPem);
|
||||||
|
await this.wrapPrivateKeyWithPassword(newPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPayload() {
|
||||||
|
return {
|
||||||
|
public_key_pem: await this.exportPublicKeyPEM(),
|
||||||
|
wrapped_private_key: btoa(String.fromCharCode(...new Uint8Array(this.wrappedPrivateKey))),
|
||||||
|
salt: btoa(String.fromCharCode(...this.salt)),
|
||||||
|
iv: btoa(String.fromCharCode(...this.iv)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.vault = new UserCryptoVault();
|
||||||
|
|
||||||
|
async function VaultSetupUserCryptoAndSend(password) {
|
||||||
|
await window.vault.generateKeyPair();
|
||||||
|
await window.vault.wrapPrivateKeyWithPassword(password);
|
||||||
|
const payload = await window.vault.exportPayload();
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function VaultUnlockPrivateKey(password, keyData) {
|
||||||
|
await window.vault.unlockPrivateKey(
|
||||||
|
Uint8Array.from(atob(keyData.wrapped_private_key), c => c.charCodeAt(0)),
|
||||||
|
Uint8Array.from(atob(keyData.salt), c => c.charCodeAt(0)),
|
||||||
|
Uint8Array.from(atob(keyData.iv), c => c.charCodeAt(0)),
|
||||||
|
password,
|
||||||
|
keyData.public_key_pem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function VaultChangePassword(old_password, new_password, keyData) {
|
||||||
|
await window.vault.changePassword(old_password, new_password,
|
||||||
|
Uint8Array.from(atob(keyData.wrapped_private_key), c => c.charCodeAt(0)),
|
||||||
|
Uint8Array.from(atob(keyData.salt), c => c.charCodeAt(0)),
|
||||||
|
Uint8Array.from(atob(keyData.iv), c => c.charCodeAt(0)),
|
||||||
|
keyData.public_key_pem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
8
components/web/static_files/manifest.json
Normal file
8
components/web/static_files/manifest.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "EHLOcomputer",
|
||||||
|
"short_name": "EHLO",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#fff",
|
||||||
|
"description": "EHLOcomputer"
|
||||||
|
}
|
||||||
21
components/web/templates/auth/authenticate.html
Normal file
21
components/web/templates/auth/authenticate.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% if not request.headers.get("Hx-Request") %}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||||
|
<ul>
|
||||||
|
<li>Welcome 👋</li>
|
||||||
|
<li>Login / Register</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="login-grid">
|
||||||
|
{% include "auth/includes/login/login.html" %}
|
||||||
|
{% with hidden=True %}
|
||||||
|
{% include "auth/includes/register/register.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</section>
|
||||||
|
{% endblock body %}
|
||||||
82
components/web/templates/auth/includes/login/login.html
Normal file
82
components/web/templates/auth/includes/login/login.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{#
|
||||||
|
"hidden" is used to include both login and register forms in main.html while only showing either/or
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div id="login-form" class="login-register" {{ '.hidden' if hidden }}>
|
||||||
|
<form
|
||||||
|
data-loading-disable
|
||||||
|
hx-trigger="submit throttle:1s"
|
||||||
|
hx-post="/auth/login/webauthn/options">
|
||||||
|
<fieldset>
|
||||||
|
<label for="webauthn-login">Who is going to login?</label>
|
||||||
|
<input type="text" id="webauthn-login" name="login"
|
||||||
|
autocomplete="webauthn"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required>
|
||||||
|
<small>You will be asked for your passkey.</small>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<button type="submit" id="authenticate">Login</button>
|
||||||
|
</fieldset>
|
||||||
|
<hr>
|
||||||
|
<section>
|
||||||
|
❔ <a href="" class="no-text-decoration" hx-on:click="event.preventDefault();document.getElementById('auth-options').toggleAttribute('hidden')">Other authentication options</a>
|
||||||
|
<div id="auth-options" hidden>
|
||||||
|
<hr>
|
||||||
|
<p>Use an alternative authentication method:</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="no-text-decoration" id="token-authentication"
|
||||||
|
hx-post="/auth/login/request/start"
|
||||||
|
hx-target="#body-main"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Send a login request to this user, if logged in
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="no-text-decoration"
|
||||||
|
_="on click
|
||||||
|
halt the event
|
||||||
|
set value of #token-login to value of #webauthn-login
|
||||||
|
take @hidden from <form/> in #login-form for closest <form/>
|
||||||
|
end">Use a terminal to confirm a generated token
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
data-loading-disable
|
||||||
|
hx-trigger="submit throttle:1s"
|
||||||
|
hx-target="#login-form"
|
||||||
|
hx-post="/auth/login/token" hidden>
|
||||||
|
<fieldset>
|
||||||
|
<label for="token-login">Who is going to login?</label>
|
||||||
|
<input type="text" id="token-login" name="login"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">
|
||||||
|
Start authentication
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
A token will be generated and needs to be validated via command line:
|
||||||
|
<code class="pointer" hx-on:click="!window.s?s=this.textContent:null;navigator.clipboard.writeText(s);this.textContent='Copied';setTimeout(()=>{this.textContent=s}, 1000)">./ctrl -t</code>
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
<p>
|
||||||
|
<a href="#"
|
||||||
|
_="on click
|
||||||
|
halt the event
|
||||||
|
set value of #webauthn-login to value of #token-login
|
||||||
|
take @hidden from <form/> in #login-form for closest <form/>
|
||||||
|
end">↩ Back to <mark>passkey</mark> authentication
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
{#
|
||||||
|
"hidden" is used to include both login and register forms in main.html while only showing either/or
|
||||||
|
#}
|
||||||
|
|
||||||
|
<form
|
||||||
|
data-loading-disable
|
||||||
|
hx-trigger="submit throttle:1s"
|
||||||
|
hx-post="/auth/register/token"
|
||||||
|
class="login-register" id="register-form" {{ 'hidden' if hidden }}>
|
||||||
|
<label for="webauthn-register">Pick a username</label>
|
||||||
|
<input type="text" id="webauthn-register" name="login"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required>
|
||||||
|
<button type="submit" id="register">Next</button>
|
||||||
|
<hr>
|
||||||
|
<p>
|
||||||
|
A token will be generated and needs to be validated via <i>command line</i>:<br>
|
||||||
|
<code class="pointer" hx-on:click="!window.s?s=this.textContent:null;navigator.clipboard.writeText(s);this.textContent='Copied';setTimeout(()=>{this.textContent=s}, 1000)">./ctrl -t</code>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
38
components/web/templates/auth/login/request/confirm.html
Normal file
38
components/web/templates/auth/login/request/confirm.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% if not request.headers.get("Hx-Request") %}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block menu %}
|
||||||
|
{% endblock menu %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<center _="
|
||||||
|
on load
|
||||||
|
set #login's value to '{{ login|e }}'
|
||||||
|
trigger click on #authenticate
|
||||||
|
end
|
||||||
|
on proxyAuthSuccess from body
|
||||||
|
put '<center>Thank you 👍</center>' into #proxy-auth-message
|
||||||
|
end">
|
||||||
|
<article>
|
||||||
|
<h5>WebAuthn Proxy Authentication</h5>
|
||||||
|
<p>Authenticating as <mark>{{ login|e }}</mark></p>
|
||||||
|
<form>
|
||||||
|
<input type="hidden" id="login" name="login">
|
||||||
|
<hr>
|
||||||
|
<div id="proxy-auth-message">
|
||||||
|
<p>If you are already logged in, your session will be upheld and not replaced
|
||||||
|
by the current authentication process.</p>
|
||||||
|
<a id="authenticate" hidden
|
||||||
|
hx-post="/auth/login/webauthn/options"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</center>
|
||||||
|
|
||||||
|
{% endblock body %}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<dialog open id="auth-login-request" hx-ext="ignore:loading-states">
|
||||||
|
<article>
|
||||||
|
<header>Incoming login request</header>
|
||||||
|
<p>
|
||||||
|
Someone wants to login with your username. You can confirm or ignore the login request.
|
||||||
|
Please review the details below.
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<form>
|
||||||
|
<button
|
||||||
|
hx-on::after-request="event.detail.successful==true?this.closest('dialog').remove():null"
|
||||||
|
hx-post="/auth/login/request/confirm/internal/{{ request.view_args.get("request_token") }}">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button class="secondary" hx-on:click="this.closest('dialog').remove()">Ignore request</button>
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
24
components/web/templates/auth/login/request/start.html
Normal file
24
components/web/templates/auth/login/request/start.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<article
|
||||||
|
hx-get="/auth/login/request/check/{{ data.request_token }}"
|
||||||
|
hx-trigger="every 1s"
|
||||||
|
hx-ext="ignore:loading-states">
|
||||||
|
<header>Authentication request</header>
|
||||||
|
<h6>How to proceed</h6>
|
||||||
|
<ol>
|
||||||
|
{% if data.request_issued_to_user %}
|
||||||
|
<li>A request was sent to logged in users matching that name.</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>You may also use this link (click to copy):
|
||||||
|
<p>
|
||||||
|
<b class="pointer" hx-on:click="!window.s?s=this.textContent:null;navigator.clipboard.writeText(s);this.textContent='Copied 👍';setTimeout(()=>{this.textContent=s}, 1000)">
|
||||||
|
{{- request.origin }}/auth/login/request/confirm/{{ data.request_token -}}
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<footer>
|
||||||
|
<button _="on load call countdownSeconds(me, {{ AUTH_REQUEST_TIMEOUT }}) end"
|
||||||
|
hx-get="/"
|
||||||
|
hx-target="#body-main" hx-push-url="true" hx-confirm="Cancel process?">Cancel process</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
29
components/web/templates/auth/login/token.html
Normal file
29
components/web/templates/auth/login/token.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<form
|
||||||
|
data-loading-disable
|
||||||
|
hx-trigger="submit throttle:1s"
|
||||||
|
hx-post="/auth/login/token/verify">
|
||||||
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<mark>{{ token }}</mark>
|
||||||
|
</header>
|
||||||
|
<p>A token intention was saved. Please ask an administrator to verify your token.</p>
|
||||||
|
<footer>
|
||||||
|
<fieldset>
|
||||||
|
<label for="confirmation_code">Token confirmation code</label>
|
||||||
|
<input type="text" id="confirmation_code" name="confirmation_code"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required>
|
||||||
|
<button type="submit"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Validate
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</form>
|
||||||
18
components/web/templates/auth/register/request/confirm.html
Normal file
18
components/web/templates/auth/register/request/confirm.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<dialog open id="auth-register-request" hx-ext="ignore:loading-states">
|
||||||
|
<article>
|
||||||
|
<header>Registration request</header>
|
||||||
|
<p>
|
||||||
|
Someone wants to register as {{ request.view_args.get("login") }}.
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<form>
|
||||||
|
<button
|
||||||
|
hx-on::after-request="event.detail.successful==true?this.closest('dialog').remove():null"
|
||||||
|
hx-post="asd">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button class="secondary" hx-on:click="this.closest('dialog').remove()">Ignore request</button>
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
33
components/web/templates/auth/register/token.html
Normal file
33
components/web/templates/auth/register/token.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<form
|
||||||
|
data-loading-disable
|
||||||
|
hx-trigger="submit throttle:1s"
|
||||||
|
hx-post="/auth/register/webauthn/options">
|
||||||
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<mark>{{ token }}</mark>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
A token intention was saved. Please ask an administrator to verify your token.<br>
|
||||||
|
After validation you will be asked to register a passkey.
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<fieldset>
|
||||||
|
<label for="confirmation_code">Token confirmation code</label>
|
||||||
|
<input type="text" id="confirmation_code" name="confirmation_code"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required>
|
||||||
|
<button type="submit"
|
||||||
|
hx-target="this"
|
||||||
|
id="register"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Validate <small>and register</small>
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</form>
|
||||||
59
components/web/templates/base.html
Normal file
59
components/web/templates/base.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" on>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="htmx-config" content='{"htmx.config.allowScriptTags":"false"}'>
|
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||||
|
<title>Gyst</title>
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
|
<link rel="manifest" href="/static/manifest.json" />
|
||||||
|
<link href="/static/css/pico-custom.css" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
const theme = localStorage.getItem('theme') || 'light';
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
</script>
|
||||||
|
<script type="text/hyperscript" src="/static/hyperscript/common._hs"></script>
|
||||||
|
<script src="/static/js/htmx.org/htmx.org.js"></script>
|
||||||
|
<script src="/static/js/htmx.org/htmx.org.loading-states.js" defer></script>
|
||||||
|
<script src="/static/js/htmx.org/htmx.org.json-enc.js" defer></script>
|
||||||
|
<script src="/static/js/htmx.org/htmx.org.ws.js" defer></script>
|
||||||
|
<script src="/static/js/_hyperscript/_hyperscript.js"></script>
|
||||||
|
<script src="/static/js/_hyperscript/_hyperscript.template.js"></script>
|
||||||
|
<script src="/static/js/simplewebauthn-browser/simplewebauthn-browser.js"></script>
|
||||||
|
<script defer src="/static/js/uservault.js"></script>
|
||||||
|
<script defer src="/static/js/site.js" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body _="install bodydefault" hx-ext="loading-states">
|
||||||
|
<header hx-push-url="true">
|
||||||
|
{% block menu %}
|
||||||
|
{% include "includes/menu.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"></nav>
|
||||||
|
{% endblock %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="body-main">
|
||||||
|
{% block body %}
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
{% block footer %}
|
||||||
|
{% if session["login"] %}
|
||||||
|
<div hx-ext="ws" ws-connect="/ws">
|
||||||
|
<input ws-send
|
||||||
|
type="hidden"
|
||||||
|
hx-vals='js:{"path": (event.detail.requestConfig?event.detail.requestConfig.path:location.pathname)}'
|
||||||
|
hx-trigger="htmx:beforeRequest from:body, htmx:wsOpen from:body once" />
|
||||||
|
<div id="ws-recv"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% include "includes/notifications.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
473
components/web/templates/ehlo.svg
Normal file
473
components/web/templates/ehlo.svg
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
data-root-id="1789911234"
|
||||||
|
y="393.867"
|
||||||
|
viewBox="222 306 99.498751 202.0306"
|
||||||
|
xml:space="preserve"
|
||||||
|
height="55.973846"
|
||||||
|
width="41.894211"
|
||||||
|
preserveAspectRatio="xMinYMin"
|
||||||
|
data-layer-role="icon"
|
||||||
|
version="1.1"
|
||||||
|
id="main"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs198" /><sodipodi:namedview
|
||||||
|
id="namedview196"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="11.313709"
|
||||||
|
inkscape:cx="46.182912"
|
||||||
|
inkscape:cy="33.45499"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1355"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg194" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="9606358357583"
|
||||||
|
d="m 341.72,407.2056 v 22.59 l 14.83,-5.56 14.84,-5.57 v -22.25 l -0.46,-0.17 -14.38,5.39 z"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
d="m 343.54,407.8756 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.92 v -22.59 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.73,-3.65 19.45,-7.33 29.21,-10.95 0.55,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.06,0.6 1.06,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.81 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1,-0.02 -1.82,-0.55 -1.82,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="1450563757583"
|
||||||
|
id="path4" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="7794898357583"
|
||||||
|
d="m 341.72,384.9456 v 22.6 l 14.83,-5.57 14.84,-5.56 v -22.25 l -0.46,-0.17 -14.38,5.39 z"
|
||||||
|
id="path6" />
|
||||||
|
<path
|
||||||
|
d="m 343.54,385.6256 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.92 v -22.59 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.73,-3.65 19.45,-7.33 29.21,-10.95 0.55,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.06,0.6 1.06,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.81 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1,-0.02 -1.82,-0.55 -1.82,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="1743656757583"
|
||||||
|
id="path8" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="7187344557583"
|
||||||
|
d="m 341.72,362.6956 v 22.59 l 14.83,-5.56 14.84,-5.56 v -22.25 l -0.46,-0.18 -14.38,5.4 z"
|
||||||
|
id="path10" />
|
||||||
|
<path
|
||||||
|
d="m 343.54,363.3756 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.91 v -22.59 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.73,-3.65 19.45,-7.33 29.21,-10.95 0.55,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.06,0.6 1.06,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1,-0.01 -1.82,-0.54 -1.82,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="2760847857583"
|
||||||
|
id="path12" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="441548657583"
|
||||||
|
d="m 341.26,340.7856 30.13,11.3 -14.84,5.56 -14.83,5.56 -14.84,-5.56 -14.83,-5.56 v -0.34 l 14.38,-5.4 z"
|
||||||
|
id="path16" />
|
||||||
|
<path
|
||||||
|
d="m 342.27,339.8056 30.03,11.26 c 0.87,0.32 1.17,1.04 0.67,1.61 -0.17,0.19 -0.4,0.33 -0.66,0.43 l -29.67,11.13 c -0.59,0.22 -1.28,0.2 -1.83,0 l -29.68,-11.13 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 v -0.34 c 0,-0.47 0.42,-0.87 1.03,-1.07 l 29.1,-10.91 c 0.62,-0.23 1.36,-0.2 1.92,0.04 z m 25.49,12.28 -26.49,-9.93 -26.04,9.76 26.49,9.93 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="9961090457583"
|
||||||
|
id="path18" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="2133061957583"
|
||||||
|
d="m 328.59,469.5806 v 22.59 l 14.84,-5.56 14.83,-5.56 v -22.26 l -0.45,-0.17 -14.38,5.4 z"
|
||||||
|
id="path20" />
|
||||||
|
<path
|
||||||
|
d="m 330.42,470.2606 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.91 v -22.6 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.74,-3.65 19.45,-7.33 29.21,-10.96 0.55,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.07,0.6 1.07,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.06,0.22 -1,0.01 -1.82,-0.52 -1.82,-1.18 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3231403457583"
|
||||||
|
id="path22" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="7123448557583"
|
||||||
|
d="m 328.59,469.5806 v 22.59 l -14.83,-5.56 -14.84,-5.56 v -22.26 l 0.46,-0.17 14.38,5.4 z"
|
||||||
|
id="path24" />
|
||||||
|
<path
|
||||||
|
d="m 330.42,469.5806 v 22.6 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -29.52,-11.07 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -22.25 c 0,-0.48 0.44,-0.89 1.07,-1.08 l 0.31,-0.12 c 0.58,-0.22 1.27,-0.2 1.81,0 9.76,3.62 19.48,7.3 29.22,10.96 0.56,0.21 0.89,0.61 0.9,1.02 z m -3.65,20.55 v -19.87 l -26.03,-9.76 v 19.87 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3816026157583"
|
||||||
|
id="path26" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="295138857583"
|
||||||
|
d="m 328.14,447.6706 30.12,11.3 -14.83,5.56 -14.84,5.56 -14.83,-5.56 -14.84,-5.56 v -0.35 l 14.38,-5.39 z"
|
||||||
|
id="path28" />
|
||||||
|
<path
|
||||||
|
d="m 329.14,446.6806 30.03,11.26 c 0.87,0.32 1.17,1.04 0.67,1.61 -0.16,0.19 -0.4,0.33 -0.66,0.43 l -29.67,11.13 c -0.59,0.22 -1.28,0.2 -1.83,0 l -29.67,-11.12 c -0.58,-0.22 -0.91,-0.62 -0.91,-1.02 l -0.01,-0.34 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 29.1,-10.91 c 0.62,-0.25 1.37,-0.22 1.92,0.02 z m 25.49,12.28 -26.49,-9.93 -26.04,9.76 26.49,9.93 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="6677420757583"
|
||||||
|
id="path30" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="5201516857583"
|
||||||
|
d="m 323.07,449.4006 v 14.87 l 9.76,-3.66 9.77,-3.66 v -14.65 l -0.3,-0.11 -9.47,3.55 z"
|
||||||
|
id="path32" />
|
||||||
|
<path
|
||||||
|
d="m 324.89,450.0806 v 12.15 l 15.88,-5.95 v -12.15 z m -3.65,14.19 v -14.87 c 0,-0.41 0.33,-0.8 0.91,-1.02 6.41,-2.4 12.81,-4.83 19.23,-7.21 0.55,-0.2 1.23,-0.22 1.81,0 l 0.15,0.06 c 0.63,0.19 1.07,0.6 1.07,1.08 v 14.65 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -19.41,7.28 c -0.29,0.13 -0.64,0.2 -1.02,0.2 -1,0 -1.82,-0.53 -1.82,-1.19 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3958404557583"
|
||||||
|
id="path34" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="1580884457583"
|
||||||
|
d="m 323.07,449.4006 v 14.87 l -9.77,-3.66 -9.76,-3.66 v -14.65 l 0.3,-0.11 9.46,3.55 z"
|
||||||
|
id="path36" />
|
||||||
|
<path
|
||||||
|
d="m 324.89,449.4006 v 14.87 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -19.38,-7.27 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -14.65 c 0,-0.48 0.44,-0.89 1.07,-1.08 l 0.15,-0.06 c 0.58,-0.22 1.27,-0.2 1.81,0 6.42,2.38 12.82,4.81 19.23,7.21 0.57,0.24 0.9,0.63 0.91,1.04 z m -3.65,12.83 v -12.15 l -15.89,-5.96 v 12.15 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="1621429757583"
|
||||||
|
id="path38" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="6172045157583"
|
||||||
|
d="m 323.07,434.4456 v 14.87 l -9.77,-3.66 -9.76,-3.66 v -14.64 l 0.3,-0.12 9.46,3.55 z"
|
||||||
|
id="path40" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<path
|
||||||
|
d="m 324.89,449.0956 v 14.87 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -19.39,-7.27 c -0.58,-0.22 -0.91,-0.62 -0.91,-1.02 l -0.01,-14.65 c 0,-0.48 0.44,-0.89 1.07,-1.08 l 0.15,-0.06 c 0.58,-0.22 1.27,-0.2 1.81,0 6.42,2.38 12.82,4.81 19.23,7.21 0.58,0.24 0.91,0.63 0.92,1.04 z m -3.65,12.83 v -12.15 l -15.89,-5.96 v 12.15 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="6494413657583"
|
||||||
|
id="path54" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="5956742857583"
|
||||||
|
d="m 323.07,434.4456 v 14.87 l 9.76,-3.66 9.77,-3.66 v -14.64 l -0.3,-0.12 -9.47,3.55 z"
|
||||||
|
id="path56" />
|
||||||
|
<path
|
||||||
|
d="m 324.89,435.1256 v 12.15 l 15.88,-5.96 v -12.15 z m -3.65,14.19 v -14.87 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 6.41,-2.4 12.8,-4.82 19.23,-7.21 0.55,-0.2 1.23,-0.22 1.81,0 l 0.15,0.06 c 0.63,0.19 1.07,0.6 1.07,1.08 v 14.65 c 0,0.41 -0.33,0.8 -0.91,1.02 l -19.39,7.27 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.01,-0.01 -1.83,-0.54 -1.83,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="9454038857583"
|
||||||
|
id="path58" />
|
||||||
|
<path
|
||||||
|
d="m 324.89,434.4456 v 14.87 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -19.39,-7.27 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 l -0.01,-14.65 c 0,-0.48 0.44,-0.89 1.07,-1.08 l 0.15,-0.06 c 0.58,-0.22 1.27,-0.2 1.81,0 6.42,2.38 12.82,4.81 19.23,7.21 0.58,0.24 0.91,0.64 0.92,1.04 z m -3.65,12.83 v -12.15 l -15.89,-5.96 v 12.15 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="1803903757583"
|
||||||
|
id="path60" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="779481357583"
|
||||||
|
d="m 322.77,420.0256 19.83,7.44 -9.77,3.66 -9.76,3.66 -9.77,-3.66 -9.76,-3.66 v -0.23 l 9.46,-3.55 z"
|
||||||
|
id="path62" />
|
||||||
|
<path
|
||||||
|
d="m 323.77,419.0456 19.73,7.4 c 0.87,0.32 1.17,1.04 0.67,1.61 -0.16,0.19 -0.4,0.33 -0.67,0.43 l -19.53,7.33 c -0.58,0.22 -1.28,0.21 -1.83,0 l -19.52,-7.32 c -0.58,-0.22 -0.91,-0.62 -0.91,-1.02 l -0.01,-0.22 c 0,-0.48 0.44,-0.89 1.07,-1.08 l 19.08,-7.16 c 0.62,-0.24 1.36,-0.21 1.92,0.03 z m 15.19,8.42 -16.19,-6.07 -15.9,5.96 16.2,6.07 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="9365930757583"
|
||||||
|
id="path64" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="7444217857583"
|
||||||
|
d="m 323.07,419.9156 v 14.87 l 9.76,-3.66 9.77,-3.66 v -14.65 l -0.3,-0.11 -9.47,3.55 z"
|
||||||
|
id="path66" />
|
||||||
|
<path
|
||||||
|
d="m 324.89,420.5956 v 12.15 l 15.88,-5.96 v -12.15 z m -3.65,14.19 v -14.87 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 6.41,-2.4 12.8,-4.82 19.23,-7.21 0.55,-0.2 1.23,-0.22 1.81,0 l 0.15,0.06 c 0.63,0.19 1.07,0.6 1.07,1.08 v 14.65 c 0,0.41 -0.33,0.8 -0.91,1.02 l -19.39,7.27 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.01,-0.01 -1.83,-0.54 -1.83,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3865956757583"
|
||||||
|
id="path68" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="942033357583"
|
||||||
|
d="m 289.08,484.2606 v 22.59 l 14.84,-5.56 14.84,-5.57 v -22.25 l -0.46,-0.17 -14.38,5.39 z"
|
||||||
|
id="path70" />
|
||||||
|
<path
|
||||||
|
d="m 290.91,484.9406 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.91 v -22.59 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.73,-3.65 19.46,-7.33 29.22,-10.96 0.55,-0.2 1.23,-0.22 1.81,0 l 0.31,0.11 c 0.63,0.19 1.07,0.6 1.07,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -29.56,11.09 c -0.29,0.13 -0.64,0.2 -1.02,0.2 -1,0 -1.82,-0.53 -1.82,-1.18 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="7037992457583"
|
||||||
|
id="path72" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="6308042957583"
|
||||||
|
d="m 289.08,484.2606 v 22.59 l -14.83,-5.56 -14.84,-5.57 v -22.25 l 0.46,-0.17 14.38,5.39 z"
|
||||||
|
id="path74" />
|
||||||
|
<path
|
||||||
|
d="m 290.9,484.2606 v 22.59 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.38,0 -0.73,-0.07 -1.02,-0.2 l -29.56,-11.09 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -22.25 c 0,-0.48 0.44,-0.89 1.07,-1.08 l 0.31,-0.11 c 0.58,-0.22 1.27,-0.2 1.81,0 9.76,3.62 19.49,7.31 29.22,10.96 0.59,0.21 0.91,0.61 0.91,1.02 z m -3.64,20.54 v -19.87 l -26.03,-9.76 v 19.87 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="7618107257583"
|
||||||
|
id="path76" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="1478899657583"
|
||||||
|
d="m 288.63,462.3406 30.13,11.3 -14.84,5.56 -14.84,5.57 -14.83,-5.57 -14.84,-5.56 v -0.34 l 14.38,-5.39 z"
|
||||||
|
id="path78" />
|
||||||
|
<path
|
||||||
|
d="m 289.53,461.3206 30.13,11.3 c 0.87,0.32 1.17,1.05 0.67,1.61 -0.17,0.19 -0.4,0.33 -0.67,0.43 l -29.66,11.13 c -0.59,0.22 -1.28,0.2 -1.83,0 l -29.66,-11.13 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -0.34 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 29.07,-10.9 c 0.58,-0.21 1.27,-0.2 1.81,0 z m 25.6,12.32 -26.5,-9.94 -26.04,9.77 26.5,9.93 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="680562657584"
|
||||||
|
id="path80" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="345685157584"
|
||||||
|
d="m 283.56,464.0806 v 14.87 l 9.76,-3.66 9.77,-3.66 v -14.65 l -0.3,-0.11 -9.47,3.55 z"
|
||||||
|
id="path82" />
|
||||||
|
<path
|
||||||
|
d="m 285.38,464.7606 v 12.14 l 15.88,-5.95 v -12.15 z m -3.64,14.19 v -14.87 c 0.01,-0.41 0.33,-0.8 0.91,-1.02 l 19.14,-7.17 c 0.56,-0.24 1.3,-0.27 1.92,-0.04 l 0.19,0.07 c 0.61,0.19 1.03,0.6 1.03,1.06 v 14.65 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -19.41,7.28 c -0.29,0.13 -0.64,0.2 -1.02,0.2 -1.03,0 -1.84,-0.53 -1.84,-1.18 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="6528423157584"
|
||||||
|
id="path84" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="4069241657584"
|
||||||
|
d="m 283.56,464.0806 v 14.87 l -9.77,-3.66 -9.76,-3.66 v -14.65 l 0.3,-0.11 9.46,3.55 z"
|
||||||
|
id="path86" />
|
||||||
|
<path
|
||||||
|
d="m 285.38,464.0806 v 14.87 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -19.38,-7.27 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -14.65 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 0.19,-0.07 c 0.62,-0.23 1.36,-0.2 1.92,0.04 l 19.13,7.17 c 0.56,0.22 0.89,0.62 0.9,1.03 z m -3.64,12.82 v -12.15 l -15.89,-5.96 v 12.15 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="8398219957584"
|
||||||
|
id="path88" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="7793349557584"
|
||||||
|
d="m 283.26,449.6606 19.83,7.43 -9.77,3.66 -9.76,3.66 -9.77,-3.66 -9.76,-3.66 v -0.22 l 9.46,-3.55 z"
|
||||||
|
id="path90" />
|
||||||
|
<path
|
||||||
|
d="m 284.16,448.6306 19.83,7.44 c 0.87,0.32 1.17,1.05 0.67,1.61 -0.17,0.19 -0.4,0.33 -0.67,0.43 l -19.52,7.32 c -0.62,0.23 -1.36,0.2 -1.92,-0.04 l -19.43,-7.29 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -0.23 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 19.12,-7.17 c 0.58,-0.2 1.27,-0.19 1.81,0.01 z m 15.3,8.46 -16.2,-6.08 -15.9,5.96 16.19,6.07 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3034884757584"
|
||||||
|
id="path92" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="2604738457584"
|
||||||
|
d="m 283.56,449.5406 v 14.87 l 9.76,-3.66 9.77,-3.66 v -14.65 l -0.3,-0.11 -9.47,3.55 z"
|
||||||
|
id="path94" />
|
||||||
|
<path
|
||||||
|
d="m 285.38,450.2206 v 12.14 l 15.88,-5.95 v -12.15 z m -3.64,14.19 v -14.87 c 0.01,-0.41 0.33,-0.8 0.91,-1.02 l 19.14,-7.17 c 0.56,-0.24 1.3,-0.27 1.92,-0.04 l 0.19,0.07 c 0.61,0.19 1.03,0.6 1.03,1.06 v 14.65 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -19.41,7.28 c -0.29,0.13 -0.64,0.2 -1.02,0.2 -1.03,0.01 -1.84,-0.52 -1.84,-1.18 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3062495157584"
|
||||||
|
id="path96" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="2184610857584"
|
||||||
|
d="m 283.56,449.5406 v 14.87 l -9.77,-3.66 -9.76,-3.66 v -14.65 l 0.3,-0.11 9.46,3.55 z"
|
||||||
|
id="path98" />
|
||||||
|
<path
|
||||||
|
d="m 285.38,449.5406 v 14.87 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -19.38,-7.27 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -14.65 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 0.19,-0.07 c 0.62,-0.23 1.36,-0.2 1.92,0.04 l 19.13,7.17 c 0.56,0.23 0.89,0.63 0.9,1.03 z m -3.64,12.83 v -12.15 l -15.89,-5.96 v 12.15 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="2923438857584"
|
||||||
|
id="path100" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="8989465357584"
|
||||||
|
d="m 283.56,434.5956 v 14.87 l 9.76,-3.66 9.77,-3.67 v -14.64 l -0.3,-0.11 -9.47,3.55 z"
|
||||||
|
id="path126" />
|
||||||
|
<path
|
||||||
|
d="m 285.38,435.2656 v 12.15 l 15.88,-5.96 v -12.14 z m -3.65,14.2 v -14.87 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 l 19.22,-7.21 c 0.55,-0.21 1.24,-0.22 1.83,0 l 0.19,0.07 c 0.61,0.19 1.03,0.6 1.03,1.06 v 14.65 c -0.01,0.41 -0.33,0.8 -0.91,1.02 l -19.39,7.27 c -0.3,0.14 -0.66,0.22 -1.06,0.22 -1.01,-0.01 -1.83,-0.54 -1.83,-1.19 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3208264357584"
|
||||||
|
id="path128" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="6384954657584"
|
||||||
|
d="m 283.26,420.1756 19.83,7.43 -9.77,3.66 -9.76,3.66 -9.77,-3.66 -9.76,-3.66 v -0.22 l 9.46,-3.55 z"
|
||||||
|
id="path130" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="4592353657584"
|
||||||
|
d="m 297.72,389.5956 v 45.11 l 29.62,-11.11 29.61,-11.1 v -44.43 l -0.9,-0.34 -28.71,10.77 z"
|
||||||
|
id="path132" />
|
||||||
|
<path
|
||||||
|
d="m 284.26,419.1856 c 12.62,7.39 15.63,6.74 11.78,12.42 l -11.58,4.34 c -0.58,0.22 -1.27,0.2 -1.81,0 -6.52,-2.42 -13.03,-4.88 -19.54,-7.32 -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 v -0.22 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 19.08,-7.16 c 0.62,-0.23 1.37,-0.2 1.92,0.04 z m -1,2.35 -15.9,5.96 16.2,6.08 12.35,-4.63 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="9450294557584"
|
||||||
|
id="path134" />
|
||||||
|
<path
|
||||||
|
d="m 299.54,390.2756 v 42.38 l 55.59,-20.84 v -42.38 z m -3.64,44.43 v -45.1 h 0.01 c 0,-0.41 0.33,-0.81 0.91,-1.02 19.44,-7.29 38.84,-14.64 58.32,-21.87 0.55,-0.2 1.23,-0.22 1.81,0 l 0.76,0.29 c 0.63,0.19 1.06,0.6 1.06,1.08 v 44.43 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -59.08,22.16 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.01,-0.03 -1.82,-0.56 -1.82,-1.21 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="6430986557584"
|
||||||
|
id="path136" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="9348698757584"
|
||||||
|
d="m 297.72,389.5956 v 45.11 l -29.62,-11.11 -29.62,-11.1 v -44.43 l 0.91,-0.34 28.71,10.77 z"
|
||||||
|
id="path138" />
|
||||||
|
<path
|
||||||
|
d="m 299.54,389.5956 v 45.1 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -59.08,-22.16 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -44.43 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 0.76,-0.29 c 0.58,-0.22 1.27,-0.2 1.81,0 19.48,7.23 38.89,14.58 58.33,21.87 0.58,0.25 0.9,0.65 0.91,1.05 z m -3.64,43.06 v -42.38 l -55.59,-20.84 v 42.38 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3957168657584"
|
||||||
|
id="path140" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="8868414357584"
|
||||||
|
d="m 296.81,345.8556 60.14,22.55 -29.61,11.11 -29.62,11.11 -29.62,-11.11 -29.62,-11.11 v -0.68 l 28.71,-10.76 z"
|
||||||
|
id="path142" />
|
||||||
|
<path
|
||||||
|
d="m 297.81,344.87258 60.04,22.17519 c 0.87,0.3151 1.17,1.03393 0.67,1.58535 -0.16,0.18709 -0.4,0.32495 -0.66,0.42342 l -59.23,21.86994 c -0.58,0.21663 -1.28,0.19694 -1.83,0 l -59.22,-21.86994 c -0.58,-0.21663 -0.91,-0.60066 -0.91,-1.00438 h -0.01 v -0.66959 c 0,-0.47265 0.44,-0.87638 1.06,-1.06347 l 58.17,-21.48591 c 0.63,-0.22648 1.37,-0.19694 1.92,0.0394 z m 55.51,23.16973 -56.51,-20.86556 -55.61,20.53076 56.51,20.86556 z"
|
||||||
|
style="fill:#1f371c;stroke-width:0.992315"
|
||||||
|
data-layer-id="9727774157584"
|
||||||
|
id="path144" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="1487892457584"
|
||||||
|
d="m 297.72,342.0356 v 35.94 l 23.6,-8.85 23.6,-8.85 v -35.4 l -0.72,-0.27 -22.88,8.58 z"
|
||||||
|
id="path146" />
|
||||||
|
<path
|
||||||
|
d="m 299.54,342.7056 v 33.22 l 43.56,-16.33 v -33.22 z m -3.64,35.27 v -35.94 h 0.01 c 0,-0.41 0.33,-0.81 0.91,-1.02 l 46.47,-17.43 c 0.54,-0.2 1.23,-0.22 1.82,0 l 0.58,0.22 c 0.63,0.19 1.06,0.6 1.06,1.08 v 35.4 h -0.01 c 0,0.41 -0.33,0.81 -0.91,1.02 l -47.05,17.64 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.02,-0.01 -1.83,-0.54 -1.83,-1.19 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="9691931757584"
|
||||||
|
id="path148" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="6820932457584"
|
||||||
|
d="m 297.72,342.0356 v 35.94 l -23.6,-8.85 -23.6,-8.85 v -35.4 l 0.72,-0.27 22.88,8.58 z"
|
||||||
|
id="path150" />
|
||||||
|
<path
|
||||||
|
d="m 299.54,342.0356 v 35.94 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -47.05,-17.64 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -35.4 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 0.58,-0.22 c 0.58,-0.22 1.27,-0.2 1.81,0 l 46.47,17.43 c 0.59,0.22 0.91,0.62 0.92,1.03 z m -3.64,33.9 v -33.23 l -43.56,-16.33 v 33.22 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="7442382257584"
|
||||||
|
id="path152" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="9722892357584"
|
||||||
|
d="m 296.99,307.1756 47.93,17.97 -23.6,8.85 -23.6,8.85 -23.6,-8.85 -23.6,-8.85 v -0.54 l 22.87,-8.58 z"
|
||||||
|
id="path154" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="4118168557584"
|
||||||
|
d="m 253.49,440.2856 v 22.59 l 14.84,-5.56 14.84,-5.56 v -22.26 l -0.46,-0.17 -14.38,5.4 z"
|
||||||
|
id="path156" />
|
||||||
|
<path
|
||||||
|
d="m 298,306.1956 47.83,17.93 c 0.87,0.32 1.17,1.04 0.67,1.61 -0.17,0.19 -0.4,0.33 -0.66,0.43 l -47.2,17.7 c -0.58,0.22 -1.28,0.2 -1.83,0 l -47.19,-17.7 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 h -0.01 v -0.54 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 46.36,-17.38 a 2.62,2.62 0 0 1 1.91,0.03 z m 43.29,18.95 -44.29,-16.61 -43.57,16.34 44.29,16.61 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="8637196957584"
|
||||||
|
id="path158" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="3385433857584"
|
||||||
|
d="m 253.49,395.7756 v 22.6 l 14.84,-5.57 14.84,-5.56 v -22.25 l -0.46,-0.17 -14.38,5.39 z"
|
||||||
|
id="path160" />
|
||||||
|
<path
|
||||||
|
d="m 255.32,440.9656 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.91 v -22.59 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.74,-3.65 19.46,-7.33 29.21,-10.95 0.54,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.07,0.6 1.07,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.81 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.01,-0.01 -1.83,-0.54 -1.83,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="9596223657584"
|
||||||
|
id="path162" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="247978957584"
|
||||||
|
d="m 253.49,440.2856 v 22.59 l -14.83,-5.56 -14.84,-5.56 v -22.26 l 0.46,-0.17 14.38,5.4 z"
|
||||||
|
id="path164" />
|
||||||
|
<path
|
||||||
|
d="m 255.32,440.2856 v 22.59 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -29.52,-11.07 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 H 222 v -22.25 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 0.31,-0.12 c 0.58,-0.22 1.27,-0.2 1.81,0 9.76,3.62 19.48,7.3 29.22,10.96 0.58,0.22 0.91,0.62 0.92,1.03 z m -3.65,20.55 v -19.87 l -26.03,-9.76 v 19.87 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="6608060657584"
|
||||||
|
id="path166" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="7667564457584"
|
||||||
|
d="m 253.04,418.3756 30.12,11.29 -14.83,5.57 -14.84,5.56 -14.83,-5.56 -14.84,-5.57 v -0.34 l 14.38,-5.39 z"
|
||||||
|
id="path168" />
|
||||||
|
<path
|
||||||
|
d="m 254.04,417.3856 30.03,11.26 c 0.87,0.32 1.17,1.04 0.67,1.61 -0.16,0.18 -0.4,0.33 -0.66,0.43 l -29.67,11.13 c -0.59,0.22 -1.28,0.21 -1.83,0 l -29.67,-11.12 c -0.58,-0.22 -0.91,-0.62 -0.91,-1.02 v -0.34 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 29.1,-10.91 a 2.57,2.57 0 0 1 1.91,0.02 z m 25.49,12.28 -26.49,-9.93 -26.04,9.76 26.49,9.93 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="478809457584"
|
||||||
|
id="path170" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="5588052457584"
|
||||||
|
d="m 253.49,418.0356 v 22.59 l 14.84,-5.56 14.84,-5.57 v -22.25 l -0.46,-0.17 -14.38,5.39 z"
|
||||||
|
id="path172" />
|
||||||
|
<path
|
||||||
|
d="m 255.32,418.7056 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.92 v -22.59 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.74,-3.65 19.46,-7.33 29.21,-10.95 0.54,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.07,0.6 1.07,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.81 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.01,-0.02 -1.83,-0.55 -1.83,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="3939018457584"
|
||||||
|
id="path174" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="5675089457584"
|
||||||
|
d="m 253.49,418.0356 v 22.59 l -14.83,-5.56 -14.84,-5.57 v -22.25 l 0.46,-0.17 14.38,5.39 z"
|
||||||
|
id="path176" />
|
||||||
|
<path
|
||||||
|
d="m 255.32,418.0356 v 22.59 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -29.52,-11.07 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 H 222 v -22.25 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 0.31,-0.12 c 0.58,-0.22 1.27,-0.2 1.81,0 9.76,3.62 19.48,7.31 29.22,10.96 0.58,0.22 0.91,0.62 0.92,1.03 z m -3.65,20.55 v -19.88 l -26.03,-9.76 v 19.87 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="8352683157584"
|
||||||
|
id="path178" />
|
||||||
|
<path
|
||||||
|
d="m 255.32,396.4556 v 19.87 l 26.03,-9.76 v -19.87 z m -3.65,21.92 v -22.59 h 0.01 c 0,-0.41 0.33,-0.8 0.91,-1.02 9.74,-3.65 19.46,-7.33 29.21,-10.95 0.54,-0.2 1.23,-0.22 1.81,0 l 0.31,0.12 c 0.63,0.19 1.07,0.6 1.07,1.08 v 22.25 h -0.01 c 0,0.41 -0.33,0.8 -0.91,1.02 l -29.52,11.07 c -0.3,0.14 -0.66,0.22 -1.05,0.22 -1.01,-0.02 -1.83,-0.55 -1.83,-1.2 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="8764937357584"
|
||||||
|
id="path180" />
|
||||||
|
<path
|
||||||
|
class="st1"
|
||||||
|
style="clip-rule:evenodd;fill:#487149;fill-rule:evenodd"
|
||||||
|
data-layer-id="497436657584"
|
||||||
|
d="m 253.49,395.7756 v 22.6 l -14.83,-5.57 -14.84,-5.56 v -22.25 l 0.46,-0.17 14.38,5.39 z"
|
||||||
|
id="path182" />
|
||||||
|
<path
|
||||||
|
d="m 255.32,395.7756 v 22.59 c 0,0.65 -0.82,1.18 -1.82,1.18 -0.39,0 -0.76,-0.08 -1.05,-0.22 l -29.52,-11.07 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 H 222 v -22.25 c 0,-0.48 0.44,-0.89 1.06,-1.08 l 0.31,-0.12 c 0.58,-0.22 1.27,-0.2 1.81,0 9.76,3.62 19.48,7.3 29.22,10.96 0.58,0.23 0.91,0.62 0.92,1.03 z m -3.65,20.55 v -19.87 l -26.03,-9.76 v 19.87 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="1981471957584"
|
||||||
|
id="path184" />
|
||||||
|
<path
|
||||||
|
class="st0"
|
||||||
|
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||||
|
data-layer-id="2428798657584"
|
||||||
|
d="m 253.04,373.8656 30.12,11.3 -14.83,5.56 -14.84,5.57 -14.83,-5.57 -14.84,-5.56 v -0.34 l 14.38,-5.39 z"
|
||||||
|
id="path186" />
|
||||||
|
<path
|
||||||
|
d="m 254.04,372.8856 30.03,11.26 c 0.87,0.32 1.17,1.04 0.67,1.61 -0.16,0.19 -0.4,0.33 -0.66,0.43 l -29.67,11.13 c -0.59,0.22 -1.28,0.2 -1.83,0 l -29.67,-11.12 c -0.58,-0.22 -0.91,-0.61 -0.91,-1.02 v -0.34 c 0,-0.47 0.42,-0.87 1.03,-1.06 l 29.1,-10.91 a 2.57,2.57 0 0 1 1.91,0.02 z m 25.49,12.28 -26.49,-9.93 -26.04,9.76 26.49,9.93 z"
|
||||||
|
style="fill:#1f371c"
|
||||||
|
data-layer-id="8056849757584"
|
||||||
|
id="path188" />
|
||||||
|
<path
|
||||||
|
class="st2"
|
||||||
|
d="m 310.21,351.1556 c 3.66,-1.37 6.62,-0.56 6.62,1.82 0,2.37 -2.96,5.41 -6.62,6.78 -3.66,1.37 -6.62,0.56 -6.62,-1.82 0,-2.37 2.96,-5.41 6.62,-6.78 z m 22.22,-8.33 c 3.66,-1.37 6.62,-0.56 6.62,1.82 0,2.37 -2.96,5.41 -6.62,6.78 -3.66,1.37 -6.62,0.56 -6.62,-1.82 0,-2.37 2.96,-5.41 6.62,-6.78 z"
|
||||||
|
style="clip-rule:evenodd;fill:#1f371c;fill-rule:evenodd"
|
||||||
|
data-layer-id="8843842657584"
|
||||||
|
id="path190" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 26 KiB |
309
components/web/templates/includes/form_builder.html
Normal file
309
components/web/templates/includes/form_builder.html
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
{#
|
||||||
|
|
||||||
|
DATA FIELDS
|
||||||
|
current_data: dict
|
||||||
|
schema: BaseModel.model_json_schema()
|
||||||
|
system_fields: Optional[list] # An optional list of fields to disable when "system"
|
||||||
|
# is not part of a user's ACL
|
||||||
|
root_key: Optional[str] # A string to encapsulate a name in,
|
||||||
|
# i.e. a root_key "profile" for a field "email" becomes "profile.email"
|
||||||
|
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% for k, v in schema.properties.items() if k not in without %}
|
||||||
|
{% if v.type in ["text", "email", "number"] %}
|
||||||
|
{% set readonly = True if "readonly" in v.input_extra else False %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }} {{ "🚫" if readonly }}</label>
|
||||||
|
<input {{ v.input_extra|safe }} id="{{ v.form_id }}"
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
type="{{ v.type }}"
|
||||||
|
value="{% if current_data[k] == None %}{{ v.default }}{% else %}{{ current_data[k] }}{% endif %}">
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% elif v.type in ["date", "datetime-local"] %}
|
||||||
|
|
||||||
|
{% set readonly = True if "readonly" in v.input_extra else False %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }} {{ "🚫" if readonly }}</label>
|
||||||
|
<input {{ v.input_extra|safe }} id="{{ v.form_id }}"
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
type="{{ v.type }}"
|
||||||
|
value="{% if current_data[k] == None %}{{ v.default }}{% else %}{{ current_data[k] }}{% endif %}">
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% elif v.type == "select:multi" or v.type == "select" %}
|
||||||
|
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<select {{ v.input_extra|safe }}
|
||||||
|
{% if v.type == "select:multi" %}
|
||||||
|
multiple
|
||||||
|
size="5"
|
||||||
|
{% endif %}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
id="{{ v.form_id }}">
|
||||||
|
{% for option in v.options %}
|
||||||
|
<option value="{{ option["value"] }}" {{ "selected" if option["value"] in current_data[k] or option["value"] == "" and not current_data[k] }}>{{ option["name"] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% elif v.type == "tresor" %}
|
||||||
|
{% if request.path == "/profile/" %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %} class="keypair">
|
||||||
|
<label>{{ v.title }}</label>
|
||||||
|
<input id="{{ v.form_id }}" type="hidden" {{ v.input_extra|safe }}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
value="{% if current_data[k] == None %}{{ v.default }}{% else %}{{ current_data[k] }}{% endif %}" />
|
||||||
|
{% if not current_data[k] %}
|
||||||
|
<input form="_ignore" id="{{ v.form_id }}-password" name="{{ v.form_id }}-password" type="password" {{ v.input_extra|safe }} />
|
||||||
|
<small>Password</small>
|
||||||
|
<input form="_ignore" id="{{ v.form_id }}-password2" name="{{ v.form_id }}-password2" type="password" {{ v.input_extra|safe }} />
|
||||||
|
<small>Confirm password</small>
|
||||||
|
<a role="button" href="#" _="on click halt the event
|
||||||
|
if not value of #{{ v.form_id }}-password
|
||||||
|
trigger notification(level: 'validationError', message: 'Missing password', duration: 3000, locations: ['{{ v.form_id }}-password'])
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
if value of #{{ v.form_id }}-password != value of #{{ v.form_id }}-password2
|
||||||
|
trigger notification(level: 'validationError', message: 'Passwords do not match', duration: 3000, locations: ['{{ v.form_id }}-password', '{{ v.form_id }}-password2'])
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
call VaultSetupUserCryptoAndSend(value of #{{ v.form_id }}-password)
|
||||||
|
set value of #{{ v.form_id }} to (result as JSON)
|
||||||
|
call window.vault.lock()
|
||||||
|
add @disabled to <input[type=password]/> in closest <fieldset/>
|
||||||
|
put '<mark>Pending</mark> - please save changes to apply.' after me then remove me
|
||||||
|
on exception(error) trigger notification(level: 'error', title: 'Tresor error', message: error, duration: 3000)">
|
||||||
|
Setup encryption
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<input form="_ignore" id="old-{{ v.form_id }}" name="old-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} />
|
||||||
|
<small>Current password</small>
|
||||||
|
<input form="_ignore" id="new-{{ v.form_id }}" name="new-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} />
|
||||||
|
<small>New password</small>
|
||||||
|
<input form="_ignore" id="new2-{{ v.form_id }}" name="new2-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} />
|
||||||
|
<small>Confirm new password</small>
|
||||||
|
<a role="button" href="#" _="install confirmButton on confirmedButton halt the event
|
||||||
|
if not value of #old-{{ v.form_id }}
|
||||||
|
trigger notification(level: 'validationError', message: 'Missing current password', duration: 3000, locations: ['old-{{ v.form_id }}'])
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
if not value of #new-{{ v.form_id }}
|
||||||
|
trigger notification(level: 'validationError', message: 'Missing new password', duration: 3000, locations: ['new-{{ v.form_id }}'])
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
if value of #new-{{ v.form_id }} != value of #new2-{{ v.form_id }}
|
||||||
|
trigger notification(level: 'validationError', message: 'Passwords do not match', duration: 3000, locations: ['new-{{ v.form_id }}', 'new2-{{ v.form_id }}'])
|
||||||
|
exit
|
||||||
|
end
|
||||||
|
set previousLock to window.vault.isUnlocked()
|
||||||
|
call JSON.parse(value of #{{ v.form_id }}) set keyData to the result
|
||||||
|
call VaultChangePassword(value of #old-{{ v.form_id }}, value of #new-{{ v.form_id }}, keyData)
|
||||||
|
call window.vault.exportPayload()
|
||||||
|
set value of #{{ v.form_id }} to (result as JSON)
|
||||||
|
add @disabled to closest <fieldset/>
|
||||||
|
if not previousLock call window.vault.lock() end
|
||||||
|
put '<mark>Pending</mark> - please save changes to apply.' after me then remove me
|
||||||
|
on exception(error) trigger notification(level: 'error', title: 'Tresor error', message: error, duration: 3000)">
|
||||||
|
Change password
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif v.type == "datalist" %}
|
||||||
|
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<input list="{{ v.form_id }}-datalist"
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
id="{{ v.form_id }}"
|
||||||
|
value="{% if current_data[k] == None %}{{ v.default }}{% else %}{{ current_data[k] }}{% endif %}" {{ v.input_extra|safe }}>
|
||||||
|
<datalist
|
||||||
|
id="{{ v.form_id }}-datalist">
|
||||||
|
{% for option in v.options %}
|
||||||
|
<option value="{{ option }}">
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% elif (v.type == "users:multi" or v.type == "users") and "system" in session["acl"] %}
|
||||||
|
|
||||||
|
{% if not request.form_options.users or request.form_options.users == [] %}
|
||||||
|
<p>No users found</p>
|
||||||
|
{% else %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<select {{ v.input_extra|safe }}
|
||||||
|
{% if v.type == "users:multi" %}
|
||||||
|
multiple
|
||||||
|
size="5"
|
||||||
|
{% endif %}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
id="{{ v.form_id }}">
|
||||||
|
{% for option in request.form_options.users %}
|
||||||
|
<option value="{{ option["value"] }}" {{ "selected" if option["value"] in current_data[k] }}>{{ option["name"] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif v.type == "emailusers:multi" or v.type == "emailusers" %}
|
||||||
|
{% if request.form_options.emailusers == [] %}
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<p><mark>No email users available</mark></p>
|
||||||
|
{% else %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<select {{ v.input_extra|safe }}
|
||||||
|
{% if v.type == "emailusers:multi" %}
|
||||||
|
multiple
|
||||||
|
size="5"
|
||||||
|
{% endif %}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
id="{{ v.form_id }}">
|
||||||
|
{% for option in request.form_options.emailusers %}
|
||||||
|
<option value="{{ option["value"] }}" {{ "selected" if option["value"] in current_data[k]|map(attribute="id")|list }}>{{ option["name"] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif v.type == "list:text" or v.type == "list:number" %}
|
||||||
|
|
||||||
|
{% set readonly = True if "readonly" in v.input_extra else False %}
|
||||||
|
<template id="list:item">
|
||||||
|
<div>
|
||||||
|
<input {{ v.input_extra|safe }}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
type="{{ "text" if v.type == "list:text" else "number" }}"
|
||||||
|
placeholder="New element">
|
||||||
|
<small><a href="#" hx-on:click="event.preventDefault();this.closest('div').remove()">Remove</a></small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<fieldset data-loading-disable id="{{ v.form_id }}" _="on click from .add-list-item in me halt the event then render #list:item then put the result at the end of me end">
|
||||||
|
<legend>{{ v.title }} <a {{ "hidden" if readonly }} href="#" class="add-list-item">Add</a> {{ "🚫" if readonly }}</legend>
|
||||||
|
{% for list_value in current_data[k] or v.default %}
|
||||||
|
<div>
|
||||||
|
<input {{ v.input_extra|safe }}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
type="{{ "text" if v.type == "list:text" else "number" }}"
|
||||||
|
value="{{ list_value }}">
|
||||||
|
<small {{ "hidden" if readonly }}><a href="#" hx-on:click="event.preventDefault();this.closest('div').remove()">Remove</a></small>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif v.type == "radio" %}
|
||||||
|
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<legend for="{{ v.form_id }}">{{ v.title }}</legend>
|
||||||
|
{% for radio_value in v.enum %}
|
||||||
|
<label>
|
||||||
|
<input {{ v.input_extra|safe }}
|
||||||
|
id="{{ v.form_id }}"
|
||||||
|
type="radio"
|
||||||
|
value="{{ radio_value }}"
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
{{ "checked" if (current_data[k] != None and current_data[k] == radio_value) or (current_data[k] == None and v.default == radio_value) }} />
|
||||||
|
{{ radio_value|capitalize }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% elif v.type == "keypair" %}
|
||||||
|
|
||||||
|
{% if not request.form_options.keypairs or request.form_options.keypairs == [] %}
|
||||||
|
<p>No key pairs available</p>
|
||||||
|
{% else %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field keypair" disabled{% endif %} class="keypair">
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<select {{ v.input_extra|safe }}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
id="{{ v.form_id }}"
|
||||||
|
_="
|
||||||
|
on change or load
|
||||||
|
set keypairId to (value of <option:checked/> in me) as String
|
||||||
|
put '' into #dns-data-{{ v.form_id }}
|
||||||
|
if keypairId
|
||||||
|
add @disabled to me
|
||||||
|
put '⌛' into #dns-data-{{ v.form_id }}
|
||||||
|
getJsonUrlAsObject('/objects/keypairs/' + keypairId)
|
||||||
|
put result.details.dns_formatted into #dns-data-{{ v.form_id }}
|
||||||
|
end
|
||||||
|
catch e
|
||||||
|
put '⛔' into #dns-data-{{ v.form_id }}
|
||||||
|
finally
|
||||||
|
remove @disabled from me
|
||||||
|
trigger validate on #dns-data-{{ v.form_id }}
|
||||||
|
end">
|
||||||
|
<option value="" {{ "selected" if current_data[k] == "" }}>-- None --</option>
|
||||||
|
|
||||||
|
{% for option in request.form_options.keypairs %}
|
||||||
|
<option value="{{ option["value"] }}" {{ "selected" if option["value"] == current_data[k].id }}>{{ option["name"] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
<div>
|
||||||
|
<small><i>The required DNS TXT record's value for your convenience</i> (<span class="color-blue-200 pointer" _="on click trigger change on previous <option:checked/>">force reload</span>)</small>
|
||||||
|
<textarea rows="8" class="dns-data" readonly id="dns-data-{{ v.form_id }}" _="
|
||||||
|
on validate add @hidden to closest <div/> if my value is empty else remove @hidden from closest <div/> end end
|
||||||
|
on click call my.setSelectionRange(0, -1) end">
|
||||||
|
{{- "" -}}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif v.type == "domain" %}
|
||||||
|
|
||||||
|
{% if request.form_options.domains == [] %}
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<p><mark>No domain available</mark></p>
|
||||||
|
{% else %}
|
||||||
|
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %}>
|
||||||
|
<label for="{{ v.form_id }}">{{ v.title }}</label>
|
||||||
|
<select {{ v.input_extra|safe }}
|
||||||
|
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||||
|
id="{{ v.form_id }}">
|
||||||
|
{% for option in request.form_options.domains %}
|
||||||
|
<option value="{{ option["value"] }}" {{ "selected" if option["value"] == current_data[k].id }}>{{ option["name"] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if v.description %}
|
||||||
|
<small>{{ v.description|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
163
components/web/templates/includes/menu.html
Normal file
163
components/web/templates/includes/menu.html
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<nav hx-target="#body-main">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-get="/" class="nav-logo">
|
||||||
|
{% include "ehlo.svg" %}
|
||||||
|
</a>
|
||||||
|
<span aria-busy="true" data-loading></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% if not session["login"] %}
|
||||||
|
<li>
|
||||||
|
<a class="contrast" role="button" _="
|
||||||
|
on click
|
||||||
|
halt the event
|
||||||
|
get value of <[name=login]/> in #register-form
|
||||||
|
set value of <[name=login]/> in #login-form to it as String
|
||||||
|
toggle @hidden on .login-register
|
||||||
|
end"
|
||||||
|
href="#">
|
||||||
|
<span class="login-register">Register</span>
|
||||||
|
<span class="login-register" hidden>Login</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% if "system" in session.get("acl", []) or "user" in session.get("acl", []) %}
|
||||||
|
<li>
|
||||||
|
<details class="dropdown" hx-on:click="event.target.nodeName==='A'?this.open=false:null">
|
||||||
|
<summary>Ctrl</summary>
|
||||||
|
<ul dir="rtl">
|
||||||
|
<li>
|
||||||
|
<a href="#" class="secondary" hx-get="/objects/domains">Domains</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="secondary" hx-get="/objects/addresses">Addresses</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="secondary" hx-get="/objects/emailusers">Email Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="secondary" hx-get="/objects/keypairs">Signing keys</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if "system" in session.get("acl", []) %}
|
||||||
|
<li>
|
||||||
|
<hr>
|
||||||
|
<small>System</small>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-get="/system/users">Users</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-get="/system/logs">Logs</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-get="/system/settings">Settings</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-get="/system/status">Status</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if session["login"] %}
|
||||||
|
<li>
|
||||||
|
<details class="dropdown" hx-on:click="event.target.nodeName==='A'?this.open=false:null">
|
||||||
|
<summary>User</summary>
|
||||||
|
<ul dir="rtl">
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-get="/profile/">Profile</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<hr>
|
||||||
|
<a href="#" hx-post="/logout" hx-swap="innerHTML">Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
{% if session["login"] %}
|
||||||
|
<div id="nav-sub-primary" hx-target="#body-main" class="grid-space-between">
|
||||||
|
<div class="no-text-wrap hi">
|
||||||
|
👋 <b><a href="#" hx-get="/profile/">{{ session.get("login") or "guest" }}</a></b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% for role in session.get("acl", []) %}
|
||||||
|
<mark>{{ role }}</mark>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="nav-sub-secondary" hx-target="#body-main" class="grid-end">
|
||||||
|
<div id="ws-indicator" class="no-text-decoration" data-tooltip="{% if session["login"] %}Disconnected{% else %}Not logged in{% endif %}" hx-swap-oob="outerHTML">⭕</div>
|
||||||
|
{% if "system" in session.get("acl", []) %}
|
||||||
|
<div id="enforce-dbupdate" hx-swap-oob="outerHTML">
|
||||||
|
{% if ENFORCE_DBUPDATE %}
|
||||||
|
<button data-tooltip="Enforced database updates are enabled"
|
||||||
|
class="button-red-800"
|
||||||
|
id="enforce-dbupdate-button"
|
||||||
|
hx-get="/system/status"
|
||||||
|
_="on load call countdownSeconds(me, {{ ENFORCE_DBUPDATE }}) end">
|
||||||
|
!!!
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div id="nav-theme-toggle"
|
||||||
|
_="on updateTheme
|
||||||
|
if not localStorage.theme
|
||||||
|
if window.matchMedia('(prefers-color-scheme: light)').matches
|
||||||
|
set (@data-theme of <html/>) to 'light'
|
||||||
|
else
|
||||||
|
set (@data-theme of <html/>) to 'dark'
|
||||||
|
end
|
||||||
|
else
|
||||||
|
set (@data-theme of <html/>) to localStorage.theme
|
||||||
|
end
|
||||||
|
set my @class to (@data-theme of <html/>)
|
||||||
|
set localStorage.theme to (@data-theme of <html/>)
|
||||||
|
end
|
||||||
|
init trigger updateTheme end
|
||||||
|
on click
|
||||||
|
if I match .light
|
||||||
|
set (@data-theme of <html/>) to 'dark'
|
||||||
|
else
|
||||||
|
set (@data-theme of <html/>) to 'light'
|
||||||
|
end
|
||||||
|
set localStorage.theme to (@data-theme of <html/>)
|
||||||
|
toggle between .light and .dark
|
||||||
|
end
|
||||||
|
">💡 Theme
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if session["login"] %}
|
||||||
|
<section>
|
||||||
|
<hr>
|
||||||
|
<fieldset _="install tresorToggle" role="group" class="" {{ "disabled" if not session.profile.tresor }}>
|
||||||
|
<input hx-disable
|
||||||
|
id="vault-unlock-pin"
|
||||||
|
name="vault-unlock-pin"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
data-protonpass-ignore="true"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"/>
|
||||||
|
<a role="button" href="#" id="vault-unlock" data-tresor="{{ session.profile.tresor }}">...</a>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
82
components/web/templates/includes/notifications.html
Normal file
82
components/web/templates/includes/notifications.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{#
|
||||||
|
htmx.trigger("body", "notification", {
|
||||||
|
title: "My title",
|
||||||
|
level: "error"
|
||||||
|
message: "A bad error occured",
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
|
# ...or...
|
||||||
|
trigger notification(title: 'A title', level: 'success', message: 'A message', duration: 3000)
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div id="notification-container"
|
||||||
|
_="
|
||||||
|
init
|
||||||
|
set $notificationColorMapping to {
|
||||||
|
'error': 'notification-error',
|
||||||
|
'validationError': 'notification-warning',
|
||||||
|
'warning': 'notification-warning',
|
||||||
|
'success': 'notification-success',
|
||||||
|
'user': 'notification-user',
|
||||||
|
'system': 'notification-system'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
on notification from body
|
||||||
|
set notificationData to event.detail
|
||||||
|
set locations to notificationData.locations or []
|
||||||
|
|
||||||
|
if notificationData.level == 'validationError'
|
||||||
|
set notificationData.title to 'Data validation failed'
|
||||||
|
if length of locations > 0
|
||||||
|
if (closest <form/> to event's target)
|
||||||
|
repeat for loc in locations
|
||||||
|
set list_data to loc.match('(.*)\\.([0-9]+)')
|
||||||
|
if list_data
|
||||||
|
set list_idx to list_data[2]
|
||||||
|
set list_name to list_data[1]
|
||||||
|
add [@aria-invalid=true] to (<[name=`${list_name}`]/> in closest <form/> to event's target as Array)[list_idx]
|
||||||
|
else
|
||||||
|
add [@aria-invalid=true] to <[name=`${loc}`]/> in closest <form/> to event's target
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render #notification-template with (
|
||||||
|
title: notificationData.title,
|
||||||
|
message: notificationData.message,
|
||||||
|
colorClass: $notificationColorMapping[notificationData.level],
|
||||||
|
duration: notificationData.duration or 7000,
|
||||||
|
level: notificationData.level
|
||||||
|
)
|
||||||
|
|
||||||
|
put it at end of me
|
||||||
|
|
||||||
|
end
|
||||||
|
"></div>
|
||||||
|
|
||||||
|
<template id="notification-template">
|
||||||
|
<div class="notification ${colorClass}" _="
|
||||||
|
init
|
||||||
|
wait ${duration}ms
|
||||||
|
transition my opacity to 0 over 200ms
|
||||||
|
remove me
|
||||||
|
end
|
||||||
|
on dblclick or removeNotification
|
||||||
|
transition my opacity to 0 over 100ms
|
||||||
|
remove me
|
||||||
|
end
|
||||||
|
">
|
||||||
|
<span class="notification-title">${title}</span><br>
|
||||||
|
@if level is in ['error', 'warning', 'validationError'] set bullet to '❗' end
|
||||||
|
@if message is a Array
|
||||||
|
@repeat for m in message
|
||||||
|
<span class="notification-text">${bullet} ${m}</span><br>
|
||||||
|
@end
|
||||||
|
@else
|
||||||
|
<span class="notification-text">${bullet} ${message}</span><br>
|
||||||
|
@end
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
components/web/templates/includes/warnings.html
Normal file
12
components/web/templates/includes/warnings.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% if request.warnings %}
|
||||||
|
<div class="sticky-warning-top notification-warning">
|
||||||
|
<p>
|
||||||
|
<b>The following warnings occured during the request:</b>
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
{% for warning in request.warnings %}
|
||||||
|
<li>{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
{% if not request.form_options.domains or request.form_options.domains == [] %}
|
||||||
|
<p>No domain available</p>
|
||||||
|
{% else %}
|
||||||
|
<form class="create-object" hx-trigger="submit throttle:200ms" hx-post="/objects/addresses">
|
||||||
|
<fieldset>
|
||||||
|
<label for="details.local_part">Address</label>
|
||||||
|
<input required autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" type="text" id="details.local_part" name="details.local_part" value="" placeholder="fname.lname">
|
||||||
|
<small>Local part of the new address</small>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<label for="details.assigned_domain">Assigned domain</label>
|
||||||
|
<select autocomplete="off"
|
||||||
|
name="details.assigned_domain"
|
||||||
|
id="details.assigned_domain">
|
||||||
|
{% for option in request.form_options.domains %}
|
||||||
|
<option value="{{ option["value"] }}">{{ option["name"] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>Assign address to this domain</small>
|
||||||
|
</fieldset>
|
||||||
|
<button data-loading-disable type="submit" _="install buttonCheckHtmxResponse">Create</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<form class="create-object" hx-trigger="submit throttle:200ms" hx-post="/objects/domains">
|
||||||
|
<fieldset>
|
||||||
|
<label for="details.domain">Domain</label>
|
||||||
|
<input autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" type="text" id="details.domain" name="details.domain" value="" placeholder="example.org">
|
||||||
|
<small>A unique domain name.</small>
|
||||||
|
</fieldset>
|
||||||
|
<button data-loading-disable type="submit" _="install buttonCheckHtmxResponse">Create</button>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<form class="create-object" hx-trigger="submit throttle:200ms" hx-post="/objects/emailusers">
|
||||||
|
<fieldset>
|
||||||
|
<label for="details.username">Username</label>
|
||||||
|
<input autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" type="text" id="details.username" name="details.username" value="" placeholder="a_user">
|
||||||
|
<small>A unique username.</small>
|
||||||
|
</fieldset>
|
||||||
|
<button data-loading-disable type="submit" _="install buttonCheckHtmxResponse">Create</button>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<form class="create-object" hx-trigger="submit throttle:200ms" hx-post="/objects/keypairs">
|
||||||
|
<fieldset>
|
||||||
|
<label for="details.key_name">Key pair name</label>
|
||||||
|
<input autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" type="text" id="details.key_name" name="details.key_name" value="" placeholder="Key pair 1">
|
||||||
|
<small>A unique name.</small>
|
||||||
|
</fieldset>
|
||||||
|
<button data-loading-disable type="submit" _="install buttonCheckHtmxResponse">Create</button>
|
||||||
|
</form>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<tr _="install trCheckboxSelect">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="multiselect" name="id" value="{{ object.id }}" autocomplete="off">
|
||||||
|
<a href="#" hx-target="#body-main" hx-push-url="true" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">
|
||||||
|
{{- object.details.local_part -}}</a> -
|
||||||
|
|
||||||
|
<a class="{{ "color-blue" if session.get(request.view_args.get("object_type") ~ "_filters") else "secondary" }} " href="#" hx-target="#body-main" hx-push-url="true" hx-get="/objects/domains/{{ object.details.assigned_domain.id }}">
|
||||||
|
@{{ object.details.assigned_domain.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="created-modified">
|
||||||
|
<small _="init js return new Date('{{ object.created }}').toLocaleString() end then put result into me">{{ object.created }}</small>
|
||||||
|
{% if object.created != object.updated %}
|
||||||
|
<br>✏️ <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<tr _="install trCheckboxSelect">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="multiselect" name="id" value="{{ object.id }}" autocomplete="off">
|
||||||
|
<a href="#" hx-target="#body-main" hx-push-url="true" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">
|
||||||
|
{{- object.name -}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="created-modified">
|
||||||
|
<small _="init js return new Date('{{ object.created }}').toLocaleString() end then put result into me">{{ object.created }}</small>
|
||||||
|
{% if object.created != object.updated %}
|
||||||
|
<br>✏️ <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="multiselect" name="id" value="{{ object.id }}" autocomplete="off">
|
||||||
|
<a href="#" hx-target="#body-main" hx-push-url="true" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">
|
||||||
|
{{- object.name -}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="created-modified">
|
||||||
|
<small _="init js return new Date('{{ object.created }}').toLocaleString() end then put result into me">{{ object.created }}</small>
|
||||||
|
{% if object.created != object.updated %}
|
||||||
|
<br>✏️ <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="multiselect" name="id" value="{{ object.id }}" autocomplete="off">
|
||||||
|
<a href="#" hx-target="#body-main" hx-push-url="true" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">
|
||||||
|
{{- object.name -}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="created-modified">
|
||||||
|
<small _="init js return new Date('{{ object.created }}').toLocaleString() end then put result into me">{{ object.created }}</small>
|
||||||
|
{% if object.created != object.updated %}
|
||||||
|
<br>✏️ <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<div class="table-select grid-space-between">
|
||||||
|
{% if toggle_all_button and toggle_all_button == True %}
|
||||||
|
<div>
|
||||||
|
<button class="secondary" _="on click call setCheckboxes(#{{ request.view_args.get("object_type") }}-table-body, 'toggle') end">All</button>
|
||||||
|
<button class="secondary" _="on click call setCheckboxes(#{{ request.view_args.get("object_type") }}-table-body, 'invert') end">Invert</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if delete_button and delete_button == True %}
|
||||||
|
<button data-loading-disable
|
||||||
|
class="delete button-red"
|
||||||
|
_="install confirmButton
|
||||||
|
on htmx:afterRequest[event.detail.successful==true]
|
||||||
|
trigger submit on #{{ request.view_args.get("object_type") }}-table-search
|
||||||
|
end
|
||||||
|
"
|
||||||
|
hx-post="/objects/{{ request.view_args.get("object_type") }}/delete"
|
||||||
|
hx-target="#body-main"
|
||||||
|
hx-trigger="confirmedButton throttle:200ms"
|
||||||
|
hx-include="[name='id']">Delete</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
26
components/web/templates/objects/includes/objects/table.html
Normal file
26
components/web/templates/objects/includes/objects/table.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% set header = True if not header == False %}
|
||||||
|
{% set footer = True if not footer == False %}
|
||||||
|
{% set delete_button = True if not delete_button == False %}
|
||||||
|
{% set toggle_all_button = True if not toggle_all_button == False %}
|
||||||
|
|
||||||
|
<form id="{{ request.view_args.get("object_type") }}-table-search"
|
||||||
|
hx-trigger="load once, keyup changed from:input[name=q] delay:100ms, submit throttle:100ms, htmx:afterRequest[event.detail.successful==true] from:.create-object"
|
||||||
|
hx-post="/objects/{{ request.view_args.get("object_type") }}/search"
|
||||||
|
hx-target="#{{ request.view_args.get("object_type") }}-table-body">
|
||||||
|
<input type="search" name="q"
|
||||||
|
hx-on:keydown="event.keyCode==13?event.preventDefault():null"
|
||||||
|
placeholder="Type to search"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table id="{{ request.view_args.get("object_type") }}-table">
|
||||||
|
<tbody id="{{ request.view_args.get("object_type") }}-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "objects/includes/objects/select.html" %}
|
||||||
|
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{% include "objects/includes/objects/table_navigation.html" %}
|
||||||
|
{% for object in data.objects %}
|
||||||
|
{% include "objects/includes/objects/row/" ~ request.view_args.get("object_type") ~ ".html" %}
|
||||||
|
{% endfor %}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
{% set filters = session.get(request.view_args.get("object_type") ~ "_filters") %}
|
||||||
|
|
||||||
|
<div id="{{ request.view_args.get("object_type") }}-table-filters" class="filters" _="install objectFilters(submitForm: #{{ request.view_args.get("object_type") }}-table-search)">
|
||||||
|
<input type="text" placeholder="Filter domains"
|
||||||
|
_="init
|
||||||
|
hide <button:not(.active)/> in .filter-buttons
|
||||||
|
remove @hidden from .filter-buttons
|
||||||
|
end
|
||||||
|
on keyup
|
||||||
|
if my value is ''
|
||||||
|
hide <button:not(.active)/> in .filter-buttons
|
||||||
|
else
|
||||||
|
show <button/> in .filter-buttons when its textContent contains my value
|
||||||
|
end
|
||||||
|
show <button.active/> in .filter-buttons
|
||||||
|
if event.keyCode is 13
|
||||||
|
repeat for button in <button/> in .filter-buttons
|
||||||
|
if button's *display is not 'none' trigger click on it break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end">
|
||||||
|
|
||||||
|
<div class="filter-buttons" hidden>
|
||||||
|
{% for option in request.form_options.domains %}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="_filters"
|
||||||
|
value="assigned_domain:{{ option["value"] }}"
|
||||||
|
class="{{ "active button-blue" if option["value"] in filters["assigned_domain"]|ensurelist else "outline primary" }}">
|
||||||
|
{{ option["name"] }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="generated-filters"></div>
|
||||||
|
<template id="filter-item">
|
||||||
|
<input form="{{ request.view_args.get("object_type") }}-table-search" type="hidden" name="filters" value="${value}">
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
{% set sorting_direction = "asc" if session.get(request.view_args.get("object_type") ~ "_sorting")[1] == False else "desc" %}
|
||||||
|
{% set sorting_attr = session.get(request.view_args.get("object_type") ~ "_sorting")[0] %}
|
||||||
|
|
||||||
|
<tr id="{{ request.view_args.get("object_type") }}-table-navigation-row">
|
||||||
|
<td class="table-navigation" colspan="2">
|
||||||
|
<div class="grid-space-between">
|
||||||
|
<div>
|
||||||
|
<select name="page_size" data-loading-disable form="{{ request.view_args.get("object_type") }}-table-search" _="on change trigger submit on #{{ request.view_args.get("object_type") }}-table-search">
|
||||||
|
<option {{ "selected" if data.page_size == 1 }} value="1">1</option>
|
||||||
|
<option {{ "selected" if data.page_size == 5 }} value="5">5</option>
|
||||||
|
<option {{ "selected" if data.page_size == 10 }} value="10">10</option>
|
||||||
|
<option {{ "selected" if data.page_size == 20 }} value="20">20</option>
|
||||||
|
<option {{ "selected" if data.page_size == 50 }} value="50">50</option>
|
||||||
|
<option {{ "selected" if data.page_size == 100 }} value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<small>Items per page</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div {{ "hidden" if data.elements == 0 }}>
|
||||||
|
<select name="page" data-loading-disable form="{{ request.view_args.get("object_type") }}-table-search" _="on change trigger submit on #{{ request.view_args.get("object_type") }}-table-search">
|
||||||
|
{% for page in range(1, data.pages + 1) %}
|
||||||
|
<option {{ "selected" if page == data.page }} value="{{ page }}">{{ page }} / {{ data.pages }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>Page</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-space-between">
|
||||||
|
<div _="on click from <.paging:not(.disabled)/> in me set value of <[name='page']/> in closest <tr/> to target's @data-value then trigger submit on #{{ request.view_args.get("object_type") }}-table-search end">
|
||||||
|
<code class="paging {{ "disabled" if data.page <= 1 else "pointer" }}" data-value="1">❰❰</code>
|
||||||
|
<code class="paging {{ "disabled" if data.page <= 1 else "pointer" }}" data-value="{{ data.page - 1 }}">❬</code>
|
||||||
|
<code class="paging {{ "disabled" if data.page == data.pages else "pointer" }}" data-value="{{ data.page + 1 }}">❭</code>
|
||||||
|
<code class="paging {{ "disabled" if data.page == data.pages else "pointer" }}" data-value="{{ data.pages }}">❱❱</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b><span id="{{- request.view_args.get("object_type") -}}-count">{{- data.page_size if data.elements >= data.page_size else data.elements -}}/{{- data.elements -}}</span></b> elements
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "objects/includes/objects/table_filters/" ~ request.view_args.get("object_type") ~ ".html" %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for attribute in ["name", "created", "updated"] %}
|
||||||
|
<button class="sorting {{ "secondary outline" if sorting_attr != attribute }}" data-loading-disable
|
||||||
|
type="submit"
|
||||||
|
form="{{ request.view_args.get("object_type") }}-table-search"
|
||||||
|
name="sorting"
|
||||||
|
value="{{ attribute }}:{{ "desc" if (sorting_direction == "asc" and sorting_attr == attribute) else "asc" }}">
|
||||||
|
{{ attribute|capitalize }} {% if sorting_attr == attribute %}{{ "[A-Z]" if sorting_direction == "asc" else "[Z..A]" }}{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
50
components/web/templates/objects/object.html
Normal file
50
components/web/templates/objects/object.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{% if not request.headers.get("Hx-Request") %}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||||
|
<ul>
|
||||||
|
<li>Objects</li>
|
||||||
|
<li><a href="#" hx-target="#body-main" hx-get="/objects/{{ request.view_args.get("object_type") }}">{{ request.view_args.get("object_type")|capitalize }}</a></li>
|
||||||
|
<li><a href="#" hx-target="#body-main" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">{{ object.name }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<div class="grid split-grid">
|
||||||
|
<article>
|
||||||
|
<hgroup>
|
||||||
|
<h5 id="object-name">{{ object.name }}</h5>
|
||||||
|
<p>Modify the object details here.<br>
|
||||||
|
<br><small>A value marked with a ⚠️ symbol cannot be removed from the object's parameter assignment.</small>
|
||||||
|
<br><small>A parameter marked with a 🔒 symbol requires system permission.</small>
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="object-details"
|
||||||
|
hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#object-form"
|
||||||
|
hx-target="this"
|
||||||
|
hx-select="#object-details"
|
||||||
|
hx-select-oob="#object-name"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">
|
||||||
|
|
||||||
|
<form id="object-form" hx-trigger="submit throttle:200ms" hx-patch="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">
|
||||||
|
{% with
|
||||||
|
schema=schemas[request.view_args.object_type],
|
||||||
|
system_fields=system_fields[request.view_args.object_type],
|
||||||
|
current_data=object.details,
|
||||||
|
root_key="details"
|
||||||
|
%}
|
||||||
|
{% include "includes/form_builder.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
<button data-loading-disable data-loading-aria-busy type="submit">Update</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock body %}
|
||||||
42
components/web/templates/objects/objects.html
Normal file
42
components/web/templates/objects/objects.html
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{% if not request.headers.get("Hx-Request") %}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||||
|
<ul>
|
||||||
|
<li>Objects</li>
|
||||||
|
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ request.view_args.get("object_type")|capitalize }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<h4>Manage {{ request.view_args.get("object_type") }}</h4>
|
||||||
|
|
||||||
|
<details class="show-below-lg">
|
||||||
|
<summary role="button" class="button-slate-800">Create object</summary>
|
||||||
|
<article>
|
||||||
|
<hgroup>
|
||||||
|
<h5>New object</h5>
|
||||||
|
<p>Create an object by defining a unique name.</p>
|
||||||
|
</hgroup>
|
||||||
|
{% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %}
|
||||||
|
</article>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="grid split-grid">
|
||||||
|
<article class="hide-below-lg">
|
||||||
|
<hgroup>
|
||||||
|
<h5>New object</h5>
|
||||||
|
<p>Create an object by defining a unique name.</p>
|
||||||
|
</hgroup>
|
||||||
|
{% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %}
|
||||||
|
</article>
|
||||||
|
<article id="{{ request.view_args.get("object_type") }}-full-table">
|
||||||
|
{% include "objects/includes/objects/table.html" %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock body %}
|
||||||
75
components/web/templates/profile/includes/credentials.html
Normal file
75
components/web/templates/profile/includes/credentials.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<article id="profile-authenticators">
|
||||||
|
<h6>Authenticators</h6>
|
||||||
|
<p>The authenticator that started the session is indicated as active.</p>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col">Last login</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
<th scope="col" class="created-modified">Created / Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="token-table-body">
|
||||||
|
{% for hex_id, credential_data in data.credentials.items() %}
|
||||||
|
<tr id="profile-credential-{{ hex_id }}"
|
||||||
|
hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-credential-{{ hex_id }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-select="#profile-credential-{{ hex_id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-get="/profile/">
|
||||||
|
<th scope="row">
|
||||||
|
{% if session["cred_id"] == hex_id %}
|
||||||
|
<mark>in use</mark>
|
||||||
|
{% endif %}
|
||||||
|
<span _="install inlineHtmxRename()"
|
||||||
|
contenteditable
|
||||||
|
data-patch-parameter="friendly_name"
|
||||||
|
spellcheck="false"
|
||||||
|
hx-patch="/profile/credential/{{ hex_id }}"
|
||||||
|
hx-trigger="editContent">
|
||||||
|
{{- credential_data.friendly_name or 'John Doe' -}}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{% if credential_data.last_login %}
|
||||||
|
<span _="init js return new Date('{{ credential_data.last_login }}').toLocaleString() end then put result into me">{{ credential_data.last_login }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="#" role="button" class="button-red"
|
||||||
|
hx-confirm="Delete token?"
|
||||||
|
_="install confirmButton"
|
||||||
|
hx-trigger="confirmedButton throttle:200ms"
|
||||||
|
hx-delete="/profile/credential/{{ hex_id }}">
|
||||||
|
Remove
|
||||||
|
</a>
|
||||||
|
<a href="#" role="button" class="{{ "outline" if not credential_data.active else ""}}"
|
||||||
|
hx-patch="/profile/credential/{{ hex_id }}"
|
||||||
|
hx-vals='js:{"active": {{ "true" if not credential_data.active else "false"}}}'
|
||||||
|
hx-params="active">
|
||||||
|
{{ "Disabled" if not credential_data.active else "Enabled"}}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="created-modified">
|
||||||
|
<small _="init js return new Date('{{- credential_data.created -}}').toLocaleString() end then put result into me">{{- credential_data.created -}}</small>
|
||||||
|
{% if credential_data.created != credential_data.updated %}
|
||||||
|
<br>✏️ <small _="init js return new Date('{{- credential_data.updated -}}').toLocaleString() end then put result into me">{{- credential_data.updated -}}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" hx-post="/auth/register/webauthn/options"
|
||||||
|
data-loading-disable
|
||||||
|
id="register"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Add authenticator
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</article>
|
||||||
35
components/web/templates/profile/profile.html
Normal file
35
components/web/templates/profile/profile.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{% if not request.headers.get("Hx-Request") %}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||||
|
<ul>
|
||||||
|
<li>Profile</li>
|
||||||
|
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ session["login"] }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<article hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-form" hx-select="#profile-form" hx-get="/profile/" hx-target="#profile-form">
|
||||||
|
<h6>Profile data</h6>
|
||||||
|
<form hx-trigger="submit throttle:1s" hx-patch="/profile/edit" id="profile-form">
|
||||||
|
|
||||||
|
{% with
|
||||||
|
schema=schemas.user_profile,
|
||||||
|
current_data=data.user.profile
|
||||||
|
%}
|
||||||
|
{% include "includes/form_builder.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<button data-loading-disable data-loading-aria-busy type="submit">Save changes</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% include "profile/includes/credentials.html" %}
|
||||||
|
|
||||||
|
{% endblock body %}
|
||||||
9
components/web/templates/system/includes/logs/row.html
Normal file
9
components/web/templates/system/includes/logs/row.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<tr _="on click toggle @hidden on next .log-details end" class="pointer">
|
||||||
|
<td>{% if current_master == log.text %}🥇{% endif %} {{ log.text }}</td>
|
||||||
|
<td>{{ log.record.level.icon }}</td>
|
||||||
|
<td _="init js return new Date('{{- log.record.time.repr -}}').toLocaleString() end then put result into me">{{- log.record.time.repr -}}</td>
|
||||||
|
<td class="text-wrap"><samp>{{- log.record.message -}}</samp></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-details" hidden>
|
||||||
|
<td colspan="4"><pre>{{ log.record|toprettyjson }}</pre></td>
|
||||||
|
</tr>
|
||||||
32
components/web/templates/system/includes/logs/table.html
Normal file
32
components/web/templates/system/includes/logs/table.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{% set header = True if not header == False %}
|
||||||
|
{% set footer = True if not footer == False %}
|
||||||
|
{% set delete_button = True if not delete_button == False %}
|
||||||
|
{% set toggle_all_button = True if not toggle_all_button == False %}
|
||||||
|
|
||||||
|
<form id="system-logs-form" hx-trigger="{% if request.headers.get("Hx-Request") %}load once, {% endif %}submit, htmx:wsOpen from:body once" hx-get="/system/logs/refresh"></form>
|
||||||
|
|
||||||
|
<form id="system-logs-table-search"
|
||||||
|
hx-trigger="logsReady, keyup changed from:input[name=q] delay:100ms, submit throttle:100ms"
|
||||||
|
hx-post="/system/logs/search"
|
||||||
|
hx-target="#system-logs-table-body">
|
||||||
|
<fieldset role="group">
|
||||||
|
<input type="search" name="q"
|
||||||
|
hx-on:keydown="event.keyCode==13?event.preventDefault():null"
|
||||||
|
placeholder="Type to search"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false" />
|
||||||
|
<input form="system-logs-form" hx-trigger="click throttle:100ms, forceRefresh from:body" hx-get="/system/logs/refresh?force=1" data-loading-disable type="submit" value="↺" />
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<p>
|
||||||
|
Last remote update: <b id="system-logs-last-refresh">?</b> seconds ago<br>
|
||||||
|
<small>Logs will be updated after {{ CLUSTER_LOGS_REFRESH_AFTER }} seconds if not enforced.</small>
|
||||||
|
</p>
|
||||||
|
<table id="system-logs-table">
|
||||||
|
<tbody id="system-logs-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{% include "system/includes/logs/table_navigation.html" %}
|
||||||
|
{% for log in data.logs %}
|
||||||
|
{% include "system/includes/logs/row.html" %}
|
||||||
|
{% endfor %}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
{% set sorting_direction = "asc" if session.get("system_logs_sorting")[1] == False else "desc" %}
|
||||||
|
{% set sorting_attr = session.get("system_logs_sorting")[0] %}
|
||||||
|
|
||||||
|
<tr id="system-logs-table-navigation-row">
|
||||||
|
<td class="table-navigation" colspan="4">
|
||||||
|
<div class="grid-space-between">
|
||||||
|
<div>
|
||||||
|
<select name="page_size" data-loading-disable form="system-logs-table-search" _="on change trigger submit on #system-logs-table-search">
|
||||||
|
<option {{ "selected" if data.page_size == 1 }} value="1">1</option>
|
||||||
|
<option {{ "selected" if data.page_size == 5 }} value="5">5</option>
|
||||||
|
<option {{ "selected" if data.page_size == 10 }} value="10">10</option>
|
||||||
|
<option {{ "selected" if data.page_size == 20 }} value="20">20</option>
|
||||||
|
<option {{ "selected" if data.page_size == 50 }} value="50">50</option>
|
||||||
|
<option {{ "selected" if data.page_size == 100 }} value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<small>Items per page</small>
|
||||||
|
</div>
|
||||||
|
<div {{ "hidden" if data.elements == 0 }}>
|
||||||
|
<select name="page" data-loading-disable form="system-logs-table-search" _="on change trigger submit on #system-logs-table-search">
|
||||||
|
{% for page in range(1, data.pages + 1) %}
|
||||||
|
<option {{ "selected" if page == data.page }} value="{{ page }}">{{ page }} / {{ data.pages }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small>Page</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-space-between">
|
||||||
|
<div _="on click from <.paging:not(.disabled)/> in me set value of <[name='page']/> in closest <tr/> to target's @data-value then trigger submit on #system-logs-table-search end">
|
||||||
|
<code class="paging {{ "disabled" if data.page <= 1 else "pointer" }}" data-value="1">❰❰</code>
|
||||||
|
<code class="paging {{ "disabled" if data.page <= 1 else "pointer" }}" data-value="{{ data.page - 1 }}">❬</code>
|
||||||
|
<code class="paging {{ "disabled" if data.page == data.pages else "pointer" }}" data-value="{{ data.page + 1 }}">❭</code>
|
||||||
|
<code class="paging {{ "disabled" if data.page == data.pages else "pointer" }}" data-value="{{ data.pages }}">❱❱</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b><span id="system-logs-count">{{- data.page_size if data.elements >= data.page_size else data.elements -}}/{{- data.elements -}}</span></b> elements
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div>
|
||||||
|
{% for attribute in ["record.time.repr", "text", "record.level.no"] %}
|
||||||
|
|
||||||
|
{% if attribute == "record.time.repr" %}
|
||||||
|
{% set fname = "Log date" %}
|
||||||
|
{% elif attribute == "text" %}
|
||||||
|
{% set fname = "Node" %}
|
||||||
|
{% elif attribute == "record.level.no" %}
|
||||||
|
{% set fname = "Level" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button class="sorting {{ "secondary outline" if sorting_attr != attribute }}" data-loading-disable
|
||||||
|
type="submit"
|
||||||
|
form="system-logs-table-search"
|
||||||
|
name="sorting"
|
||||||
|
value="{{ attribute }}:{{ "desc" if (sorting_direction == "asc" and sorting_attr == attribute) else "asc" }}">
|
||||||
|
{{ fname }} {% if sorting_attr == attribute %}{{ "[A-Z]" if sorting_direction == "asc" else "[Z..A]" }}{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user