Any signed-in account could save an ntfy channel with its own `server` and a
`{"$secret": ...}` token and press Test, which sent the stored secret to that
host. The config GET/PUT, the test route and the global search's alert entries
now answer to superusers, like /secrets. The webpush key and subscribe routes
stay open to every account, and the Alerts page shows a non-superuser only the
button for its own browser. The events table in the docs gains run_finished.
286 lines
9.8 KiB
Python
286 lines
9.8 KiB
Python
"""One index of everything in this instance worth jumping to by name."""
|
|
|
|
import json
|
|
import re
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from pydantic import BaseModel
|
|
|
|
from fluksio.api.deps import (
|
|
CurrentUser,
|
|
DashboardStoreDep,
|
|
FlowControllerDep,
|
|
get_current_user,
|
|
)
|
|
from fluksio.api.routes.alerts import read_config as read_alerts_config
|
|
from fluksio.flow import modules
|
|
from fluksio.flow.controller import NODE_TYPES, FlowController
|
|
from fluksio.flow.dashboards import DashboardNotFound, DashboardStore
|
|
from fluksio.flow.messages import qualify
|
|
from fluksio.flow.secrets import get_secrets
|
|
from fluksio.flow.store import FlowNotFound
|
|
|
|
# A device never reaches this route: ``deps._device_may`` is a whitelist that
|
|
# ends in a 403, and a whole-instance index is the opposite of what a screen
|
|
# on a wall is allowed to read.
|
|
router = APIRouter(
|
|
prefix="/search", tags=["search"], dependencies=[Depends(get_current_user)]
|
|
)
|
|
|
|
Category = Literal[
|
|
"flow",
|
|
"node",
|
|
"input",
|
|
"output",
|
|
"setting",
|
|
"dashboard",
|
|
"widget",
|
|
"device",
|
|
"secret",
|
|
"module",
|
|
"worker",
|
|
"alert",
|
|
]
|
|
|
|
|
|
class SearchEntry(BaseModel):
|
|
"""One thing somebody might be looking for.
|
|
|
|
Deliberately not a route: where a category lands is the frontend's business,
|
|
and it already owns the router. This says what the thing is and what it is
|
|
called, which is all the matching needs.
|
|
"""
|
|
|
|
category: Category
|
|
#: The id the frontend routes on. For an input or output, the qualified
|
|
#: message, which is what the canvas names its label by. For a setting,
|
|
#: ``<node>.<setting>``: a node id has no dot, so the node is what comes
|
|
#: before the first one.
|
|
name: str
|
|
#: Human title, often empty — a flow is usually only its name.
|
|
title: str = ""
|
|
#: The flow a node, input or output sits in, or the dashboard a widget
|
|
#: sits on.
|
|
parent: str = ""
|
|
#: Node type, input dtype, widget type, channel kind.
|
|
kind: str = ""
|
|
#: What a setting is set to, as text. Only ever what ``setting_text`` lets
|
|
#: through, so never a credential.
|
|
detail: str = ""
|
|
|
|
|
|
#: What stands in for a value that must not be shown, or searched for.
|
|
MASK = "\u2022\u2022\u2022\u2022"
|
|
|
|
#: How much of a value the index keeps. A setting can be a whole JSON document.
|
|
_DETAIL_CAP = 80
|
|
|
|
#: Keys that tend to hold a credential. Applied to every setting, declared or
|
|
#: not: a type's author can forget its ``x-secret``, and a masked value only
|
|
#: costs a search that could not have helped.
|
|
_SENSITIVE_KEY = re.compile(r"pass|token|secret|key|auth|credential", re.IGNORECASE)
|
|
|
|
|
|
def _is_secret_ref(value: Any) -> bool:
|
|
return isinstance(value, dict) and "$secret" in value
|
|
|
|
|
|
def _secret_ref(value: dict[str, Any], names: bool) -> str:
|
|
"""A reference to a stored secret: its name, to those who may read names."""
|
|
name = value.get("$secret")
|
|
return f"secret {name}" if names and isinstance(name, str) and name else "secret"
|
|
|
|
|
|
def _scrub(value: Any, names: bool) -> Any:
|
|
"""``value`` with every credential inside it replaced.
|
|
|
|
A stored secret is named, never read. Whatever sits under a key that looks
|
|
like a credential is masked, however deep: a header or a query parameter is
|
|
as good a place for a token as any.
|
|
"""
|
|
if _is_secret_ref(value):
|
|
return _secret_ref(value, names)
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: MASK
|
|
if _SENSITIVE_KEY.search(str(key))
|
|
and item not in (None, "")
|
|
and not _is_secret_ref(item)
|
|
else _scrub(item, names)
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [_scrub(item, names) for item in value]
|
|
return value
|
|
|
|
|
|
def setting_text(
|
|
key: str, value: Any, declared: dict[str, Any] | None, names: bool
|
|
) -> str:
|
|
"""What the search may say about one setting, never its secret value.
|
|
|
|
``declared`` is the node type's schema for this setting, None where the
|
|
type has none, as for every setting of a function node. A reference to a
|
|
stored secret is named (to those who may read names). A plain value in a
|
|
field the type flags ``x-secret``, or under a key that looks like a
|
|
credential, is masked. An empty setting is "" and gets no entry.
|
|
"""
|
|
if value is None or value == "":
|
|
return ""
|
|
if _is_secret_ref(value):
|
|
text = _secret_ref(value, names)
|
|
elif (declared or {}).get("x-secret") or _SENSITIVE_KEY.search(key):
|
|
text = MASK
|
|
elif isinstance(value, dict | list):
|
|
text = json.dumps(
|
|
_scrub(value, names), separators=(",", ":"), ensure_ascii=False
|
|
)
|
|
elif isinstance(value, bool):
|
|
text = "true" if value else "false"
|
|
else:
|
|
text = str(value)
|
|
return text if len(text) <= _DETAIL_CAP else text[: _DETAIL_CAP - 1] + "\u2026"
|
|
|
|
|
|
def _build(
|
|
controller: FlowController, dashboards: DashboardStore, hub: Any, secrets: bool
|
|
) -> list[SearchEntry]:
|
|
"""Read every store once. Blocking: disk and git throughout.
|
|
|
|
# ponytail: rebuilt per call. Key it on ``controller.store.revision`` if a
|
|
# store large enough to feel it ever shows up in a profile.
|
|
"""
|
|
entries: list[SearchEntry] = []
|
|
|
|
for name in controller.store.list_flows():
|
|
try:
|
|
flow = controller.store.read_flow(name, draft=True)
|
|
except FlowNotFound:
|
|
continue
|
|
entries.append(SearchEntry(category="flow", name=flow.name, title=flow.title))
|
|
entries.extend(
|
|
SearchEntry(
|
|
category="node",
|
|
name=node.id,
|
|
title=node.title,
|
|
parent=flow.name,
|
|
kind=node.type,
|
|
)
|
|
for node in flow.nodes
|
|
)
|
|
for node in flow.nodes:
|
|
declared = (
|
|
NODE_TYPES[node.type].params_schema.get("properties", {})
|
|
if node.type in NODE_TYPES
|
|
else {}
|
|
)
|
|
for key, value in node.params.items():
|
|
text = setting_text(key, value, declared.get(key), secrets)
|
|
if text:
|
|
entries.append(
|
|
SearchEntry(
|
|
category="setting",
|
|
name=f"{node.id}.{key}",
|
|
parent=flow.name,
|
|
kind=node.type,
|
|
detail=text,
|
|
)
|
|
)
|
|
# Its boundary, as the canvas draws it: a live flow never finishes, so
|
|
# only a batch one has outputs to report.
|
|
boundary: list[tuple[Category, str, str]] = [
|
|
("input", flow_input.spec.name, flow_input.spec.dtype.value)
|
|
for flow_input in flow.inputs
|
|
]
|
|
if flow.mode == "batch":
|
|
boundary += [("output", output, "") for output in flow.outputs]
|
|
for category, declared, kind in boundary:
|
|
message = qualify(flow.name, declared)
|
|
if message:
|
|
entries.append(
|
|
SearchEntry(
|
|
category=category,
|
|
name=message,
|
|
# The label the canvas draws: bare when it is the flow's own.
|
|
title=message.removeprefix(f"{flow.name}."),
|
|
parent=flow.name,
|
|
kind=kind,
|
|
)
|
|
)
|
|
|
|
for summary in dashboards.list():
|
|
try:
|
|
dashboard = dashboards.read(summary.name, draft=True)
|
|
except DashboardNotFound:
|
|
continue
|
|
entries.append(
|
|
SearchEntry(
|
|
category="dashboard", name=dashboard.name, title=dashboard.title
|
|
)
|
|
)
|
|
entries.extend(
|
|
SearchEntry(
|
|
category="widget",
|
|
name=widget.id,
|
|
title=widget.title,
|
|
parent=dashboard.name,
|
|
kind=widget.type,
|
|
)
|
|
for widget in dashboard.widgets
|
|
)
|
|
|
|
devices = getattr(controller, "devices", None)
|
|
entries.extend(
|
|
SearchEntry(category="device", name=device.id, title=device.title)
|
|
for device in (devices.list() if devices is not None else [])
|
|
)
|
|
|
|
if secrets:
|
|
entries.extend(
|
|
SearchEntry(category="secret", name=name) for name in get_secrets().list()
|
|
)
|
|
|
|
entries.extend(
|
|
SearchEntry(category="module", name=package.name, kind=package.version)
|
|
for package in modules.info(controller.store).packages
|
|
)
|
|
|
|
entries.extend(
|
|
SearchEntry(category="worker", name=worker.name)
|
|
for worker in (hub.workers() if hub is not None else [])
|
|
)
|
|
|
|
# Named only to a superuser, like the secrets: `/alerts` answers to them.
|
|
if secrets:
|
|
entries.extend(
|
|
SearchEntry(category="alert", name=channel.name, kind=channel.kind)
|
|
for channel in read_alerts_config().channels
|
|
)
|
|
|
|
return entries
|
|
|
|
|
|
@router.get("/", response_model=list[SearchEntry])
|
|
async def read_search_index(
|
|
current_user: CurrentUser,
|
|
request: Request,
|
|
controller: FlowControllerDep,
|
|
dashboards: DashboardStoreDep,
|
|
) -> Any:
|
|
"""Everything searchable, for the client to match against as it is typed.
|
|
|
|
The whole index rather than a query: it is a few hundred short rows for an
|
|
instance of any ordinary size, so one fetch when the panel opens beats a
|
|
round trip per keystroke — and the client already has a matcher.
|
|
|
|
Secrets and alert channels are named only to a superuser, which is who
|
|
``/secrets`` and ``/alerts`` answer to.
|
|
"""
|
|
# Absent when remote workers are switched off — not a reason to fail a search.
|
|
hub = getattr(request.app.state, "worker_hub", None)
|
|
return await run_in_threadpool(
|
|
_build, controller, dashboards, hub, current_user.is_superuser
|
|
)
|