pre-Korves.Net

Signed-off-by: apeters <apeters@korves.net>
This commit is contained in:
apeters 2025-05-21 08:05:07 +00:00
commit 1d204f26b8
122 changed files with 32601 additions and 0 deletions

168
.gitignore vendored Normal file
View 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
View 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]

View 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
View 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()

View File

@ -0,0 +1,4 @@
from config.defaults import CLUSTER_PEERS
from components.cluster import Cluster
cluster = Cluster(peers=CLUSTER_PEERS, port=2102)

View 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

View 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}")

View 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 peers 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)

View 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
View 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

View 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
View 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)

View 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

View 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
)

View 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))),
]

View 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()

View 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
View 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
View 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
View 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
View 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
]

View 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)

View 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")

View 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
View 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=(",", ": "))

View 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

View 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}

View 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",
)

View 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",
)

View 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

View 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

View 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",
)

View File

@ -0,0 +1 @@
*.sh

View File

@ -0,0 +1,4 @@
*
!pico-custom.scss
!pico-custom.css
!.gitignore

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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.

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\x22/g, "&quot;")
.replace(/\x27/g, "&#039;");
}
_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);
},
};
});
}
})

File diff suppressed because it is too large Load Diff

View 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))
}
})
})()

View File

@ -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()()}}}})})();

View 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

View 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);
}

View 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
);
}

View File

@ -0,0 +1,8 @@
{
"name": "EHLOcomputer",
"short_name": "EHLO",
"start_url": "/",
"display": "standalone",
"background_color": "#fff",
"description": "EHLOcomputer"
}

View 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 %}

View 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>

View File

@ -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>

View 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 %}

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View 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 %}

View 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
">&#128161; 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 %}

View 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 '&#10071;' 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>

View 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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>&#9999;&#65039; <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
{% endif %}
</td>
</tr>

View File

@ -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>&#9999;&#65039; <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
{% endif %}
</td>
</tr>

View File

@ -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>&#9999;&#65039; <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
{% endif %}
</td>
</tr>

View File

@ -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>&#9999;&#65039; <small _="init js return new Date('{{ object.updated }}').toLocaleString() end then put result into me">{{ object.updated }}</small>
{% endif %}
</td>
</tr>

View File

@ -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>

View 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" %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View 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 %}

View 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 %}

View 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>&#9999;&#65039; <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>

View 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 %}

View 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>

View 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>

View File

@ -0,0 +1,4 @@
{% include "system/includes/logs/table_navigation.html" %}
{% for log in data.logs %}
{% include "system/includes/logs/row.html" %}
{% endfor %}

View File

@ -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