- FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing
169 lines
4.6 KiB
Python
169 lines
4.6 KiB
Python
"""
|
|
tui_events.py — Thread-safe event bus between the bot backend and the TUI.
|
|
|
|
The bot backend runs in a dedicated thread with its own asyncio event loop
|
|
(completely isolated from Textual's loop). Events are posted via a standard
|
|
queue.Queue (thread-safe), and the TUI consumer polls it from Textual's loop
|
|
using asyncio.get_event_loop().run_in_executor() bridging.
|
|
|
|
post() is safe to call from any thread or any asyncio loop.
|
|
|
|
Web frontend: call subscribe() to get a private queue that receives every
|
|
event post() delivers. Call unsubscribe() on disconnect.
|
|
"""
|
|
|
|
import asyncio
|
|
import queue
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
# Thread-safe queue — works across the bot thread and Textual's thread.
|
|
_queue: queue.Queue | None = None
|
|
_queue_lock = threading.Lock()
|
|
|
|
# Set to True when the TUI is running so tdl pipes output instead of
|
|
# writing directly to the terminal.
|
|
tui_active: bool = False
|
|
|
|
# ─── Web subscriber broadcast ─────────────────────────────────────────────────
|
|
|
|
_subscribers: list[queue.Queue] = []
|
|
_subscribers_lock = threading.Lock()
|
|
|
|
|
|
def subscribe() -> queue.Queue:
|
|
"""Return a new Queue that receives every future post(). Thread-safe."""
|
|
q: queue.Queue = queue.Queue()
|
|
with _subscribers_lock:
|
|
_subscribers.append(q)
|
|
return q
|
|
|
|
|
|
def unsubscribe(q: queue.Queue) -> None:
|
|
"""Remove a subscriber queue. Safe to call even if already removed."""
|
|
with _subscribers_lock:
|
|
try:
|
|
_subscribers.remove(q)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ─── Bot-loop channel-change signal (for web routes) ─────────────────────────
|
|
# The TUI sets this via set_bot_context() after the bot asyncio loop starts.
|
|
# Web config routes call signal_channel_changed() to wake _watch_channels().
|
|
|
|
_bot_loop: asyncio.AbstractEventLoop | None = None
|
|
_bot_ch_ev: asyncio.Event | None = None
|
|
|
|
|
|
def set_bot_context(loop: asyncio.AbstractEventLoop, event: asyncio.Event) -> None:
|
|
"""Called by _bot_main() once the bot loop and channel event exist."""
|
|
global _bot_loop, _bot_ch_ev
|
|
_bot_loop = loop
|
|
_bot_ch_ev = event
|
|
|
|
|
|
def signal_channel_changed() -> None:
|
|
"""Wake the bot's _watch_channels() coroutine from any thread."""
|
|
if _bot_loop is not None and _bot_ch_ev is not None:
|
|
_bot_loop.call_soon_threadsafe(_bot_ch_ev.set)
|
|
|
|
|
|
def init_bus() -> queue.Queue:
|
|
"""Call once from MonitorApp.on_mount() to create the queue."""
|
|
global _queue, tui_active
|
|
_queue = queue.Queue()
|
|
tui_active = True
|
|
return _queue
|
|
|
|
|
|
def get_bus() -> queue.Queue | None:
|
|
return _queue
|
|
|
|
|
|
def post(event: Any) -> None:
|
|
"""Fire-and-forget from any thread. Broadcasts to TUI queue + all subscribers."""
|
|
if _queue is not None:
|
|
try:
|
|
_queue.put_nowait(event)
|
|
except queue.Full:
|
|
pass
|
|
with _subscribers_lock:
|
|
subs = list(_subscribers)
|
|
for q in subs:
|
|
try:
|
|
q.put_nowait(event)
|
|
except queue.Full:
|
|
pass
|
|
|
|
|
|
# ─── Event types ──────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class EvDownloadQueued:
|
|
"""A file has been accepted and is waiting for tdl."""
|
|
batch_id: str
|
|
filename: str
|
|
size_mb: float
|
|
source: str
|
|
password: str | None
|
|
|
|
|
|
@dataclass
|
|
class EvDownloadStarted:
|
|
"""tdl has begun transferring this file."""
|
|
batch_id: str
|
|
filename: str
|
|
|
|
|
|
@dataclass
|
|
class EvDownloadDone:
|
|
"""File fully downloaded (tdl or Telethon fallback)."""
|
|
batch_id: str
|
|
filename: str
|
|
via: str # "tdl" | "telethon"
|
|
|
|
|
|
@dataclass
|
|
class EvDownloadFailed:
|
|
"""All download attempts failed."""
|
|
batch_id: str
|
|
filename: str
|
|
reason: str
|
|
|
|
|
|
@dataclass
|
|
class EvTdlOutput:
|
|
"""A line of output from tdl's stdout/stderr (TUI mode only)."""
|
|
line: str
|
|
|
|
|
|
@dataclass
|
|
class EvHit:
|
|
"""A scored credential hit to display in the hits panel."""
|
|
severity: str
|
|
raw: str
|
|
source: str
|
|
filename: str
|
|
reasons: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class EvChannelAdded:
|
|
"""A channel was added to the live watch list."""
|
|
channel: str | int
|
|
|
|
|
|
@dataclass
|
|
class EvChannelRemoved:
|
|
"""A channel was removed from the live watch list."""
|
|
channel: str | int
|
|
|
|
|
|
@dataclass
|
|
class EvStatus:
|
|
"""Generic one-line status message (startup, errors, etc.)."""
|
|
text: str
|
|
level: str = "info" # "info" | "warning" | "error"
|