Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Optional remote access through a Fluksio portal.
|
||||
|
||||
Nothing in here runs unless someone enrolled this installation: with no
|
||||
``cloud.json`` the connector never starts and the portal's tokens are refused
|
||||
like any other bad credential. Turning it off is deleting that one file.
|
||||
"""
|
||||
@@ -0,0 +1,158 @@
|
||||
"""What this installation knows about the portal it is enrolled with.
|
||||
|
||||
One file on the data volume, beside the OAuth keypair and for the same reason:
|
||||
it is a credential, it must survive a rebuild, and it must never be in the
|
||||
flows repository. Deleting it is the local, unilateral way to sever the
|
||||
connection — the portal's tokens stop verifying immediately, whatever the
|
||||
portal still believes.
|
||||
|
||||
The portal's public keys are stored here rather than fetched: they are pinned
|
||||
at enrolment, when a person was holding a claim code they had just read off the
|
||||
portal's own screen. A hub whose DNS or TLS is later hijacked cannot re-key an
|
||||
installation that already enrolled; it can only fail to verify.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from fluksio.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Marks a request the connector replayed off the tunnel. The value is minted
|
||||
#: per process and never leaves it, because the header itself is not evidence:
|
||||
#: anything that can reach this API directly can set one, and the difference
|
||||
#: decides whether a pairing device is handed a credential the whole internet
|
||||
#: can present. The connector overwrites it on every frame, so a browser
|
||||
#: sending its own gets nowhere from either direction.
|
||||
VIA_HEADER = "x-fluksio-via"
|
||||
VIA_PORTAL = secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloudConfig:
|
||||
portal_url: str
|
||||
ws_url: str
|
||||
installation_id: str
|
||||
token: str
|
||||
issuer: str
|
||||
jwks: dict[str, Any]
|
||||
#: The local account this installation acts as on its own behalf: what the
|
||||
#: health summary is collected as, and what a screen paired through the
|
||||
#: portal borrows for want of a person. Recorded at enrolment from whoever
|
||||
#: performed it. Portal *sessions* no longer come through here — they name
|
||||
#: a person, and are resolved to the local account mapped to them.
|
||||
local_user_id: str
|
||||
enrolled_at: str
|
||||
portal_account: str | None = None
|
||||
|
||||
|
||||
_cache: tuple[float, CloudConfig | None] | None = None
|
||||
|
||||
|
||||
def _read() -> CloudConfig | None:
|
||||
"""Load the config, re-reading only when the file has changed."""
|
||||
global _cache
|
||||
path = settings.CLOUD_CONFIG_FILE
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
_cache = (0.0, None)
|
||||
return None
|
||||
if _cache is not None and _cache[0] == mtime:
|
||||
return _cache[1]
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
config = CloudConfig(**data)
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.exception("Could not read %s; remote access stays off", path)
|
||||
_cache = (mtime, None)
|
||||
return None
|
||||
_cache = (mtime, config)
|
||||
return config
|
||||
|
||||
|
||||
def load() -> CloudConfig | None:
|
||||
return _read()
|
||||
|
||||
|
||||
def exists() -> bool:
|
||||
return settings.CLOUD_CONFIG_FILE.exists()
|
||||
|
||||
|
||||
def save(config: CloudConfig) -> None:
|
||||
path = settings.CLOUD_CONFIG_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Written through a temporary file so a crash mid-write cannot leave a
|
||||
# half-parsed credential behind, and never group- or world-readable.
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(config.__dict__, indent=2))
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def delete() -> None:
|
||||
settings.CLOUD_CONFIG_FILE.unlink(missing_ok=True)
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def decode_portal_token(token: str) -> dict[str, Any]:
|
||||
"""Validate a token the portal minted for this installation.
|
||||
|
||||
Raises ``InvalidTokenError`` for everything else, including the ordinary
|
||||
case of not being enrolled at all — which is what makes this branch free
|
||||
for an installation nobody connected.
|
||||
"""
|
||||
config = _read()
|
||||
if config is None:
|
||||
raise InvalidTokenError("this installation is not enrolled with a portal")
|
||||
|
||||
try:
|
||||
key = jwt.PyJWKSet.from_dict(config.jwks).keys[0]
|
||||
except (IndexError, jwt.PyJWKError) as exc:
|
||||
raise InvalidTokenError(f"the pinned portal key is unusable: {exc}") from exc
|
||||
|
||||
claims: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256"],
|
||||
# The audience is this installation's own id, so a token the portal
|
||||
# minted for somebody else's machine fails here.
|
||||
audience=config.installation_id,
|
||||
issuer=config.issuer,
|
||||
)
|
||||
scope = claims.get("scope")
|
||||
if scope == "panel":
|
||||
# A screen that paired through the portal. The portal named the panel
|
||||
# and nothing else; what that panel may read is decided here, by the
|
||||
# same check a panel paired on this network passes. It acts as the
|
||||
# enrolling account for want of any other, but the scope check is what
|
||||
# actually bounds it — so a token of this scope that names no panel is
|
||||
# refused rather than left holding the account it borrows.
|
||||
panel = str(claims.get("sub") or "")
|
||||
if not panel:
|
||||
raise InvalidTokenError("a panel token must name its panel")
|
||||
return {"sub": config.local_user_id, "panel": panel}
|
||||
if scope != "proxy":
|
||||
raise InvalidTokenError("not a proxy token")
|
||||
# A portal session names the person holding it, and that name is the whole
|
||||
# of their identity here: the caller resolves it to the local account it
|
||||
# was mapped to, and a portal identity nobody mapped resolves to nothing.
|
||||
# Deliberately no local account by default — the failure of a mapping must
|
||||
# be a refusal, not a fallback onto whoever enrolled.
|
||||
portal_sub = str(claims.get("sub") or "")
|
||||
if not portal_sub:
|
||||
raise InvalidTokenError("a proxy token must name its user")
|
||||
return {"portal_sub": portal_sub}
|
||||
@@ -0,0 +1,445 @@
|
||||
"""The link this installation opens to its portal.
|
||||
|
||||
Same shape as the remote-worker agent, with the direction of the favour
|
||||
reversed: there, a GPU box dials the engine so the engine can give it work;
|
||||
here, the engine dials the portal so the portal can pass on what a browser
|
||||
asked for. Both exist because the interesting machine is the one nobody can
|
||||
route to.
|
||||
|
||||
A proxied request is dispatched straight into this process's own ASGI app
|
||||
rather than over localhost HTTP. That matters beyond saving a hop: HTTP
|
||||
trigger nodes install themselves into ``app.routes`` while flows run, and an
|
||||
in-process transport sees that table live, with no port, TLS or self-address
|
||||
to discover.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fluksio.cloud import config as cloud_config
|
||||
from fluksio.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROTOCOL = 1
|
||||
HEARTBEAT_S = 20.0
|
||||
STATUS_S = 60.0
|
||||
MAX_BACKOFF_S = 30.0
|
||||
CHUNK_BYTES = 256 * 1024
|
||||
MAX_WS_MESSAGE = 1024 * 1024
|
||||
#: Only the versioned API is served over the tunnel. The MCP mount and the
|
||||
#: OAuth endpoints live outside it and stay local-only.
|
||||
ALLOWED_PREFIX = "/api/v1/"
|
||||
|
||||
|
||||
class CloudConnector:
|
||||
"""Holds one websocket to the portal open, and answers over it."""
|
||||
|
||||
def __init__(self, app: FastAPI) -> None:
|
||||
self._app = app
|
||||
self._calls: dict[str, asyncio.Task[None]] = {}
|
||||
self._streams: dict[str, asyncio.Task[None]] = {}
|
||||
self._connected = False
|
||||
self._connected_since: float | None = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Status, for the local settings screen
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
config = cloud_config.load()
|
||||
return {
|
||||
"enrolled": config is not None,
|
||||
"connected": self._connected,
|
||||
"portal_url": config.portal_url if config else None,
|
||||
# Where a browser reaches the portal, which is not always where
|
||||
# this process does: enrolment may have named a container.
|
||||
"issuer": config.issuer if config else None,
|
||||
"portal_account": config.portal_account if config else None,
|
||||
"installation_id": config.installation_id if config else None,
|
||||
"last_error": self._last_error,
|
||||
"connected_since": self._connected_since,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# The connection
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def serve_forever(self) -> None:
|
||||
backoff = 1.0
|
||||
while True:
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
# Disconnected locally while we were running.
|
||||
return
|
||||
try:
|
||||
await self._session(config)
|
||||
backoff = 1.0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._connected = False
|
||||
self._connected_since = None
|
||||
self._last_error = str(exc)
|
||||
logger.warning("Portal link down: %s — retrying in %.0fs", exc, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(MAX_BACKOFF_S, backoff * 2)
|
||||
|
||||
async def _session(self, config: cloud_config.CloudConfig) -> None:
|
||||
import websockets
|
||||
|
||||
url = f"{config.ws_url}?token={config.token}"
|
||||
async with websockets.connect(
|
||||
url, max_size=MAX_WS_MESSAGE, ping_interval=20
|
||||
) as socket:
|
||||
await socket.send(
|
||||
_dump({"op": "hello", "protocol": PROTOCOL, "app_version": _version()})
|
||||
)
|
||||
welcome = _load(await socket.recv())
|
||||
if welcome.get("op") != "welcome":
|
||||
raise RuntimeError(
|
||||
str(welcome.get("reason") or "refused by the portal")
|
||||
)
|
||||
|
||||
self._adopt_owner(config, welcome.get("owner"))
|
||||
|
||||
self._connected = True
|
||||
self._connected_since = time.time()
|
||||
self._last_error = None
|
||||
logger.info(
|
||||
"Attached to the portal as installation %s", config.installation_id
|
||||
)
|
||||
|
||||
keepalive = asyncio.create_task(self._keepalive(socket, config))
|
||||
try:
|
||||
async for raw in socket:
|
||||
frame = _load(raw)
|
||||
op = frame.get("op")
|
||||
if op == "req":
|
||||
self._start_call(socket, frame)
|
||||
elif op == "cancel":
|
||||
self._cancel(str(frame.get("id") or ""))
|
||||
elif op == "ws_open":
|
||||
self._start_stream(socket, frame)
|
||||
elif op == "ws_close":
|
||||
self._cancel_stream(str(frame.get("id") or ""))
|
||||
finally:
|
||||
keepalive.cancel()
|
||||
self._connected = False
|
||||
self._connected_since = None
|
||||
for task in list(self._calls.values()) + list(self._streams.values()):
|
||||
task.cancel()
|
||||
self._calls.clear()
|
||||
self._streams.clear()
|
||||
|
||||
@staticmethod
|
||||
def _adopt_owner(config: cloud_config.CloudConfig, owner: Any) -> None:
|
||||
"""Map the enrolling account to the portal account that owns us.
|
||||
|
||||
Enrolment does this itself. This is for the enrolments that predate
|
||||
per-user mapping: without it their owner would be refused here until
|
||||
somebody enrolled the machine again — which, for a machine reached only
|
||||
through the portal, means standing in front of it. Runs on every attach
|
||||
because it is a no-op once the mapping is there.
|
||||
|
||||
Never moves a mapping somebody else holds: that would be this code
|
||||
guessing at something enrolment was told.
|
||||
"""
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.models import User
|
||||
|
||||
if not owner:
|
||||
return
|
||||
owner_id = str(owner)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
if session.exec(
|
||||
select(User).where(User.portal_sub == owner_id)
|
||||
).first():
|
||||
return
|
||||
user = session.get(User, config.local_user_id)
|
||||
if user is None or user.portal_sub:
|
||||
return
|
||||
user.portal_sub = owner_id
|
||||
session.add(user)
|
||||
session.commit()
|
||||
logger.info("Mapped %s to the portal account that owns us", user.email)
|
||||
except Exception:
|
||||
# A mapping that could not be written is not a reason to drop the
|
||||
# link: everything else this connection does still works.
|
||||
logger.exception("Could not map the enrolling account to the portal owner")
|
||||
|
||||
async def _keepalive(self, socket: Any, config: cloud_config.CloudConfig) -> None:
|
||||
"""Heartbeats, plus a health snapshot the portal can show while offline."""
|
||||
last_status = 0.0
|
||||
while True:
|
||||
now = time.time()
|
||||
if now - last_status >= STATUS_S or last_status == 0.0:
|
||||
summary = await self._health_summary(config)
|
||||
await socket.send(
|
||||
_dump(
|
||||
{"op": "status", "summary": summary, "app_version": _version()}
|
||||
)
|
||||
)
|
||||
last_status = now
|
||||
else:
|
||||
await socket.send(_dump({"op": "heartbeat"}))
|
||||
await asyncio.sleep(HEARTBEAT_S)
|
||||
|
||||
async def _health_summary(
|
||||
self, config: cloud_config.CloudConfig
|
||||
) -> dict[str, Any] | None:
|
||||
"""This installation's own health, as the dashboard reads it.
|
||||
|
||||
Fetched through the same in-process transport as everything else, with
|
||||
a short-lived local token: the observability API is authenticated, and
|
||||
this process is entitled to mint one for the enrolling user.
|
||||
"""
|
||||
from fluksio.core import security
|
||||
|
||||
token = security.create_access_token(config.local_user_id, timedelta(minutes=5))
|
||||
try:
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
"/api/v1/observability/summary",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
summary: dict[str, Any] = response.json()
|
||||
return summary
|
||||
except Exception:
|
||||
logger.debug("Could not collect a health summary for the portal")
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Proxied requests
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self._app),
|
||||
base_url="http://installation.local",
|
||||
timeout=httpx.Timeout(300.0, connect=5.0),
|
||||
)
|
||||
|
||||
def _start_call(self, socket: Any, frame: dict[str, Any]) -> None:
|
||||
call_id = str(frame.get("id") or "")
|
||||
if not call_id:
|
||||
return
|
||||
task = asyncio.create_task(self._serve(socket, frame))
|
||||
self._calls[call_id] = task
|
||||
task.add_done_callback(lambda _t: self._calls.pop(call_id, None))
|
||||
|
||||
async def _serve(self, socket: Any, frame: dict[str, Any]) -> None:
|
||||
call_id = str(frame["id"])
|
||||
path = str(frame.get("path") or "")
|
||||
try:
|
||||
if not path.startswith(ALLOWED_PREFIX):
|
||||
# Defence in depth: the hub only ever forwards API paths, and
|
||||
# an installation that trusted anything else would be an SSRF
|
||||
# gadget aimed at its own process.
|
||||
await socket.send(
|
||||
_dump({"op": "err", "id": call_id, "message": "path not allowed"})
|
||||
)
|
||||
return
|
||||
body = frame.get("body")
|
||||
content = base64.b64decode(body) if body else None
|
||||
query = str(frame.get("query") or "")
|
||||
|
||||
headers = dict(frame.get("headers") or {})
|
||||
# Set here rather than trusted from the frame: a browser can send
|
||||
# any header it likes through the proxy, and this one decides where
|
||||
# a pairing device's credential is minted. Arriving on this socket
|
||||
# is the only thing that makes it true, and the value is this
|
||||
# process's own so nothing off the network can imitate it.
|
||||
headers[cloud_config.VIA_HEADER] = cloud_config.VIA_PORTAL
|
||||
|
||||
async with self._client() as client:
|
||||
request = client.build_request(
|
||||
str(frame.get("method") or "GET"),
|
||||
f"{path}?{query}" if query else path,
|
||||
headers=headers,
|
||||
content=content,
|
||||
)
|
||||
response = await client.send(request, stream=True)
|
||||
try:
|
||||
await socket.send(
|
||||
_dump(
|
||||
{
|
||||
"op": "res",
|
||||
"id": call_id,
|
||||
"status": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
}
|
||||
)
|
||||
)
|
||||
async for chunk in response.aiter_bytes(CHUNK_BYTES):
|
||||
await socket.send(
|
||||
_dump(
|
||||
{
|
||||
"op": "res_body",
|
||||
"id": call_id,
|
||||
"body": base64.b64encode(chunk).decode("ascii"),
|
||||
"done": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
await socket.send(
|
||||
_dump({"op": "res_body", "id": call_id, "done": True})
|
||||
)
|
||||
finally:
|
||||
await response.aclose()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Proxied %s failed: %s", path, exc)
|
||||
with contextlib.suppress(Exception):
|
||||
await socket.send(
|
||||
_dump({"op": "err", "id": call_id, "message": str(exc)})
|
||||
)
|
||||
|
||||
def _cancel(self, call_id: str) -> None:
|
||||
task = self._calls.pop(call_id, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Bridged live stream
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _start_stream(self, socket: Any, frame: dict[str, Any]) -> None:
|
||||
stream_id = str(frame.get("id") or "")
|
||||
if not stream_id:
|
||||
return
|
||||
task = asyncio.create_task(self._stream(socket, frame))
|
||||
self._streams[stream_id] = task
|
||||
task.add_done_callback(lambda _t: self._streams.pop(stream_id, None))
|
||||
|
||||
async def _stream(self, socket: Any, frame: dict[str, Any]) -> None:
|
||||
"""Feed the engine's live events down the tunnel.
|
||||
|
||||
The flows websocket is subscribed to directly rather than dialled:
|
||||
httpx cannot speak websocket, and everything that endpoint does — check
|
||||
the token, send a snapshot, forward the bus — is a few lines against
|
||||
objects this process already holds. The snapshot is built by the route
|
||||
itself, so the two cannot drift apart behind the portal's back.
|
||||
"""
|
||||
from sqlmodel import Session
|
||||
|
||||
from fluksio.api.deps import user_from_token
|
||||
from fluksio.api.routes.flows import (
|
||||
event_for_panel,
|
||||
panel_scope,
|
||||
snapshot_payload,
|
||||
)
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.flow.events import event_bus
|
||||
|
||||
stream_id = str(frame["id"])
|
||||
path = str(frame.get("path") or "")
|
||||
query = str(frame.get("query") or "")
|
||||
token = _query_value(query, "token")
|
||||
|
||||
if not path.startswith(ALLOWED_PREFIX) or not path.endswith("/flows/ws"):
|
||||
await socket.send(_dump({"op": "ws_close", "id": stream_id, "code": 1008}))
|
||||
return
|
||||
with Session(engine) as session:
|
||||
if user_from_token(session, token) is None:
|
||||
await socket.send(
|
||||
_dump({"op": "ws_close", "id": stream_id, "code": 1008})
|
||||
)
|
||||
return
|
||||
only = panel_scope(token, self._app)
|
||||
|
||||
await socket.send(_dump({"op": "ws_open_ok", "id": stream_id}))
|
||||
|
||||
controller = getattr(self._app.state, "flow_controller", None)
|
||||
if controller is not None:
|
||||
await socket.send(
|
||||
_dump(
|
||||
{
|
||||
"op": "ws_msg",
|
||||
"id": stream_id,
|
||||
"text": _dump(snapshot_payload(controller, only)),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with event_bus.subscribe() as queue:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if only is not None and event.get("type") == "dashboard_changed":
|
||||
# What this screen shows may have just changed under
|
||||
# it. Rescope, then resend the snapshot so a dashboard
|
||||
# it has only now been assigned draws values instead of
|
||||
# blanks. ``or set()`` is the point: a panel that was
|
||||
# deleted scopes to nothing, and None would widen this
|
||||
# socket to everything on the bus.
|
||||
only = panel_scope(token, self._app) or set()
|
||||
if controller is not None:
|
||||
catch_up = snapshot_payload(controller, only)
|
||||
await socket.send(
|
||||
_dump(
|
||||
{
|
||||
"op": "ws_msg",
|
||||
"id": stream_id,
|
||||
"text": _dump(catch_up),
|
||||
}
|
||||
)
|
||||
)
|
||||
elif only is not None and not event_for_panel(event, only):
|
||||
continue
|
||||
await socket.send(
|
||||
_dump({"op": "ws_msg", "id": stream_id, "text": _dump(event)})
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.debug("Live stream %s ended: %s", stream_id, exc)
|
||||
with contextlib.suppress(Exception):
|
||||
await socket.send(
|
||||
_dump({"op": "ws_close", "id": stream_id, "code": 1011})
|
||||
)
|
||||
|
||||
def _cancel_stream(self, stream_id: str) -> None:
|
||||
task = self._streams.pop(stream_id, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
|
||||
def _dump(payload: Any) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(payload, default=str)
|
||||
|
||||
|
||||
def _load(raw: Any) -> dict[str, Any]:
|
||||
import json
|
||||
|
||||
data: dict[str, Any] = json.loads(raw)
|
||||
return data
|
||||
|
||||
|
||||
def _query_value(query: str, key: str) -> str:
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
values = parse_qs(query).get(key) or [""]
|
||||
return values[0]
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
return settings.VERSION if hasattr(settings, "VERSION") else "0.1.0"
|
||||
Reference in New Issue
Block a user