fix(server): harden auth, SSE, state, scraping
- Fix SSE streams not terminating on successful jobs (C-2) - Anchor data/session paths to BASE_DIR instead of CWD (C-3) - Guard TelegramAuthManager state with RLock (H-1) - Replace millisecond job ids with uuid4 (H-2) - Always redact api_id/api_hash on export, drop include_secrets (H-3) - Enforce JSON content-type + same-origin on mutating requests (H-4) - Rate-limit auth attempts and phone-code requests (H-5) - Deep-copy StateStore.load() on all paths (H-6) - Cap FloodWait retries in forward_message (H-7) - De-duplicate forwarding handler registration (H-8) - Validate continuous channels at ingest, join scrape thread on account removal, fix refresh_config status under lock, cap SSE streams and JSON body size (M-5, M-6, M-17) - Add regression tests (33 passing) and REVIEW.md
This commit is contained in:
+478
-163
@@ -14,12 +14,13 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
@@ -54,6 +55,116 @@ SCRAPER_JOBS = ScraperJobService(STATE_STORE)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Job statuses that are terminal: once a job reaches one of these states the
|
||||
# job is considered finished and event streams / polling should stop.
|
||||
TERMINAL_JOB_STATUSES = {"done", "failed", "completed"}
|
||||
|
||||
# Maximum number of concurrently open SSE event streams. Each stream pins a
|
||||
# handler thread for up to 30 minutes, so cap them and close the oldest.
|
||||
MAX_EVENT_STREAMS = 10
|
||||
|
||||
# How long ContinuousScrapeOrchestrator.remove_account waits for a scrape
|
||||
# worker thread to exit before deleting account data best-effort (seconds).
|
||||
REMOVE_ACCOUNT_JOIN_TIMEOUT = 20.0
|
||||
|
||||
# Sentinel returned by read_json_body() when a request carries a body with a
|
||||
# non-JSON Content-Type - maps to a 415 Unsupported Media Type response.
|
||||
_JSON_CONTENT_TYPE_REJECTED = object()
|
||||
|
||||
# Sentinel returned by read_json_body() when the declared/actual body size
|
||||
# exceeds MAX_JSON_BODY_BYTES - maps to a 413 Payload Too Large response.
|
||||
_JSON_BODY_TOO_LARGE = object()
|
||||
MAX_JSON_BODY_BYTES = 1_048_576 # 1 MB
|
||||
|
||||
# ── Auth throttling ─────────────────────────────────────────────────────
|
||||
# Repeated failed submissions on the phone-code / 2FA-password endpoints are
|
||||
# throttled per (client IP, account): AUTH_MAX_FAILED_ATTEMPTS failures lock
|
||||
# the pair out for AUTH_LOCKOUT_SECONDS.
|
||||
AUTH_MAX_FAILED_ATTEMPTS = 5
|
||||
AUTH_LOCKOUT_SECONDS = 60
|
||||
# Minimum interval (seconds) between successful phone-code REQUEST calls for
|
||||
# the same (client IP, account) pair. Covers the success path so a LAN client
|
||||
# cannot loop request_phone_code and flood the victim's phone with SMS.
|
||||
AUTH_CODE_REQUEST_COOLDOWN_SECONDS = 30
|
||||
# Cap the in-memory auth tracking dict; beyond this we sweep expired entries.
|
||||
AUTH_ATTEMPTS_MAX_ENTRIES = 10_000
|
||||
_auth_attempts: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||
_auth_attempts_lock = threading.Lock()
|
||||
|
||||
|
||||
def _sweep_auth_attempts(now: float) -> None:
|
||||
"""Evict expired lockout/cooldown entries when the dict grows too large."""
|
||||
global _auth_attempts
|
||||
if len(_auth_attempts) <= AUTH_ATTEMPTS_MAX_ENTRIES:
|
||||
return
|
||||
_auth_attempts = {
|
||||
k: v
|
||||
for k, v in _auth_attempts.items()
|
||||
if (v.get("locked_until") or 0) > now or (v.get("cooldown_until") or 0) > now
|
||||
}
|
||||
|
||||
|
||||
def _check_auth_throttle(ip: str, account_id: str) -> bool:
|
||||
"""Return True if the attempt is allowed, False if currently locked out."""
|
||||
now = time.time()
|
||||
with _auth_attempts_lock:
|
||||
_sweep_auth_attempts(now)
|
||||
entry = _auth_attempts.get((ip, account_id))
|
||||
if not entry:
|
||||
return True
|
||||
locked_until = entry.get("locked_until")
|
||||
if locked_until and now < locked_until:
|
||||
return False
|
||||
if locked_until and now >= locked_until:
|
||||
# Lockout window expired - reset the counter.
|
||||
_auth_attempts.pop((ip, account_id), None)
|
||||
return True
|
||||
|
||||
|
||||
def _check_auth_code_cooldown(ip: str, account_id: str) -> bool:
|
||||
"""Return True if a code REQUEST is allowed, False if in cooldown."""
|
||||
now = time.time()
|
||||
with _auth_attempts_lock:
|
||||
_sweep_auth_attempts(now)
|
||||
entry = _auth_attempts.get((ip, account_id))
|
||||
if not entry:
|
||||
return True
|
||||
cooldown_until = entry.get("cooldown_until")
|
||||
if cooldown_until and now < cooldown_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _record_auth_failure(ip: str, account_id: str) -> None:
|
||||
now = time.time()
|
||||
with _auth_attempts_lock:
|
||||
_sweep_auth_attempts(now)
|
||||
entry = _auth_attempts.setdefault(
|
||||
(ip, account_id), {"failures": 0, "locked_until": None}
|
||||
)
|
||||
locked_until = entry.get("locked_until")
|
||||
if locked_until is not None and now < locked_until:
|
||||
return
|
||||
entry["failures"] = entry.get("failures", 0) + 1
|
||||
if entry["failures"] >= AUTH_MAX_FAILED_ATTEMPTS:
|
||||
entry["locked_until"] = now + AUTH_LOCKOUT_SECONDS
|
||||
entry["failures"] = 0
|
||||
|
||||
|
||||
def _record_auth_success(ip: str, account_id: str) -> None:
|
||||
with _auth_attempts_lock:
|
||||
_auth_attempts.pop((ip, account_id), None)
|
||||
|
||||
|
||||
def _record_auth_code_request(ip: str, account_id: str) -> None:
|
||||
"""Record a successful phone-code request to start its cooldown."""
|
||||
now = time.time()
|
||||
with _auth_attempts_lock:
|
||||
entry = _auth_attempts.setdefault(
|
||||
(ip, account_id), {"failures": 0, "locked_until": None}
|
||||
)
|
||||
entry["cooldown_until"] = now + AUTH_CODE_REQUEST_COOLDOWN_SECONDS
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -112,11 +223,41 @@ def normalize_channel_id(value: Any) -> str:
|
||||
return channel_id
|
||||
|
||||
|
||||
def export_account_state(state: Dict[str, Any], include_secrets: bool = False) -> Dict[str, Any]:
|
||||
def clean_continuous_channels(channels: Any) -> Tuple[List[str], List[str]]:
|
||||
"""Validate/normalize a continuous-scrape channel list at ingest.
|
||||
|
||||
Returns ``(cleaned, dropped)`` where ``cleaned`` holds the normalized,
|
||||
path-safe channel ids and ``dropped`` holds the raw values that were
|
||||
rejected (invalid path characters, control chars, ``.``/``..``). The
|
||||
dropped entries must NOT be persisted so they cannot become a path
|
||||
traversal vector for any future direct read.
|
||||
"""
|
||||
cleaned: List[str] = []
|
||||
dropped: List[str] = []
|
||||
if not isinstance(channels, list):
|
||||
return cleaned, dropped
|
||||
for item in channels:
|
||||
raw = str(item).strip()
|
||||
try:
|
||||
cleaned.append(normalize_channel_id(raw))
|
||||
except ValueError:
|
||||
dropped.append(raw)
|
||||
return cleaned, dropped
|
||||
|
||||
|
||||
def export_account_state(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a sanitized copy of an account state for export.
|
||||
|
||||
Credentials (api_hash, api_id) are always redacted - they are never
|
||||
included in exports, only a presence flag is exposed.
|
||||
"""
|
||||
exported = dict(state)
|
||||
if not include_secrets and "api_hash" in exported:
|
||||
if "api_hash" in exported:
|
||||
exported["api_hash_present"] = bool(exported.get("api_hash"))
|
||||
exported["api_hash"] = None
|
||||
if "api_id" in exported:
|
||||
exported["api_id_present"] = bool(exported.get("api_id"))
|
||||
exported["api_id"] = None
|
||||
return exported
|
||||
|
||||
|
||||
@@ -345,9 +486,33 @@ class JobRunner:
|
||||
self.queue: "queue.Queue[Job]" = queue.Queue()
|
||||
self.lock = threading.Lock()
|
||||
self._shutdown_flag = False
|
||||
# Active SSE event streams: stream_id -> started_at, plus any stream
|
||||
# ids that have been revoked (told to close) because the cap was hit.
|
||||
self._stream_started: Dict[str, float] = {}
|
||||
self._stream_revoked: set = set()
|
||||
self.worker = threading.Thread(target=self._run, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def register_event_stream(self) -> str:
|
||||
"""Register an active SSE stream; revoke the oldest when over the cap."""
|
||||
stream_id = uuid.uuid4().hex
|
||||
with self.lock:
|
||||
self._stream_started[stream_id] = time.time()
|
||||
if len(self._stream_started) > MAX_EVENT_STREAMS:
|
||||
oldest = min(self._stream_started, key=lambda sid: self._stream_started[sid])
|
||||
self._stream_started.pop(oldest, None)
|
||||
self._stream_revoked.add(oldest)
|
||||
return stream_id
|
||||
|
||||
def unregister_event_stream(self, stream_id: str) -> None:
|
||||
with self.lock:
|
||||
self._stream_started.pop(stream_id, None)
|
||||
self._stream_revoked.discard(stream_id)
|
||||
|
||||
def is_stream_revoked(self, stream_id: str) -> bool:
|
||||
with self.lock:
|
||||
return stream_id in self._stream_revoked
|
||||
|
||||
def create_job(self, job_type: str, title: str, payload: Dict[str, Any]) -> Job:
|
||||
if self._shutdown_flag:
|
||||
raise RuntimeError("Server is shutting down, cannot create new jobs")
|
||||
@@ -366,7 +531,7 @@ class JobRunner:
|
||||
+ f"[{datetime.now().strftime('%H:%M:%S')}] Reused existing active job for this account."
|
||||
).strip()
|
||||
return existing
|
||||
job_id = f"job-{int(time.time() * 1000)}"
|
||||
job_id = f"job-{uuid.uuid4().hex[:12]}"
|
||||
job = Job(
|
||||
job_id=job_id,
|
||||
job_type=job_type,
|
||||
@@ -459,7 +624,12 @@ class JobRunner:
|
||||
|
||||
class TelegramAuthManager:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
# Reentrant lock guarding self.auth_data / self.clients. These dicts
|
||||
# are mutated from the async event-loop thread (coroutines) and read
|
||||
# from HTTP handler threads, so every access must hold the lock.
|
||||
# RLock allows nested acquisition from helpers (_get_auth_data /
|
||||
# _set_state) called inside compound locked operations.
|
||||
self.lock = threading.RLock()
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self.thread.start()
|
||||
@@ -475,26 +645,28 @@ class TelegramAuthManager:
|
||||
return future.result()
|
||||
|
||||
def _get_auth_data(self, account_id: str) -> Dict[str, Any]:
|
||||
if account_id not in self.auth_data:
|
||||
self.auth_data[account_id] = {
|
||||
"phase": "idle",
|
||||
"status": "unknown",
|
||||
"details": "",
|
||||
"qr_url": None,
|
||||
"qr_image": None,
|
||||
"phone": None,
|
||||
"phone_code_hash": None,
|
||||
"qr_login": None,
|
||||
"qr_wait_task": None,
|
||||
"user_id": None,
|
||||
"updated_at": utc_now_iso(),
|
||||
}
|
||||
return self.auth_data[account_id]
|
||||
with self.lock:
|
||||
if account_id not in self.auth_data:
|
||||
self.auth_data[account_id] = {
|
||||
"phase": "idle",
|
||||
"status": "unknown",
|
||||
"details": "",
|
||||
"qr_url": None,
|
||||
"qr_image": None,
|
||||
"phone": None,
|
||||
"phone_code_hash": None,
|
||||
"qr_login": None,
|
||||
"qr_wait_task": None,
|
||||
"user_id": None,
|
||||
"updated_at": utc_now_iso(),
|
||||
}
|
||||
return self.auth_data[account_id]
|
||||
|
||||
def _set_state(self, account_id: str, **updates: Any) -> None:
|
||||
data = self._get_auth_data(account_id)
|
||||
data.update(updates)
|
||||
data["updated_at"] = utc_now_iso()
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
data.update(updates)
|
||||
data["updated_at"] = utc_now_iso()
|
||||
|
||||
def _make_qr_image(self, qr_url: str) -> str:
|
||||
qr = qrcode.QRCode(border=1, box_size=8)
|
||||
@@ -512,32 +684,39 @@ class TelegramAuthManager:
|
||||
api_hash = acc_state.get("api_hash")
|
||||
if not api_id or not api_hash:
|
||||
raise RuntimeError("Save api_id and api_hash first for this account.")
|
||||
if account_id not in self.clients or self.clients[account_id] is None:
|
||||
_ensure_session_wal(account_session_path(SESSION_DIR, account_id))
|
||||
self.clients[account_id] = TelegramClient(
|
||||
account_session_path(SESSION_DIR, account_id),
|
||||
api_id,
|
||||
api_hash,
|
||||
)
|
||||
client = self.clients[account_id]
|
||||
with self.lock:
|
||||
if account_id not in self.clients or self.clients[account_id] is None:
|
||||
_ensure_session_wal(account_session_path(SESSION_DIR, account_id))
|
||||
self.clients[account_id] = TelegramClient(
|
||||
account_session_path(SESSION_DIR, account_id),
|
||||
api_id,
|
||||
api_hash,
|
||||
)
|
||||
client = self.clients[account_id]
|
||||
if not client.is_connected():
|
||||
await client.connect()
|
||||
data = self._get_auth_data(account_id)
|
||||
if data.get("user_id") is None and await client.is_user_authorized():
|
||||
with self.lock:
|
||||
needs_user_id = self._get_auth_data(account_id).get("user_id") is None
|
||||
if needs_user_id and await client.is_user_authorized():
|
||||
try:
|
||||
me = await client.get_me()
|
||||
data["user_id"] = me.id
|
||||
except Exception:
|
||||
pass
|
||||
me = None
|
||||
if me is not None:
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
if data.get("user_id") is None:
|
||||
data["user_id"] = me.id
|
||||
return client
|
||||
|
||||
def auth_state(self, account_id: str) -> Dict[str, Any]:
|
||||
data = self._get_auth_data(account_id)
|
||||
acc_state = load_account(DATA_DIR, account_id)
|
||||
snapshot = dict(data)
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
snapshot = dict(data)
|
||||
snapshot.pop("qr_login", None)
|
||||
snapshot.pop("qr_wait_task", None)
|
||||
snapshot.pop("phone_code_hash", None)
|
||||
acc_state = load_account(DATA_DIR, account_id)
|
||||
snapshot["saved_credentials"] = {
|
||||
"api_id": acc_state.get("api_id"),
|
||||
"api_hash_present": bool(acc_state.get("api_hash")),
|
||||
@@ -568,7 +747,6 @@ class TelegramAuthManager:
|
||||
|
||||
async def _start_qr_login(self, account_id: str) -> Dict[str, Any]:
|
||||
client = await self._get_client(account_id)
|
||||
data = self._get_auth_data(account_id)
|
||||
if await client.is_user_authorized():
|
||||
self._set_state(
|
||||
account_id,
|
||||
@@ -579,7 +757,10 @@ class TelegramAuthManager:
|
||||
return self.auth_state(account_id)
|
||||
qr_login = await client.qr_login()
|
||||
qr_url = qr_login.url
|
||||
data["qr_login"] = qr_login
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
data["qr_login"] = qr_login
|
||||
data["updated_at"] = utc_now_iso()
|
||||
self._set_state(
|
||||
account_id,
|
||||
phase="qr_waiting",
|
||||
@@ -588,14 +769,18 @@ class TelegramAuthManager:
|
||||
qr_url=qr_url,
|
||||
qr_image=self._make_qr_image(qr_url),
|
||||
)
|
||||
data["qr_wait_task"] = self.loop.create_task(
|
||||
self._wait_for_qr_login(account_id)
|
||||
)
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
data["qr_wait_task"] = self.loop.create_task(
|
||||
self._wait_for_qr_login(account_id)
|
||||
)
|
||||
data["updated_at"] = utc_now_iso()
|
||||
return self.auth_state(account_id)
|
||||
|
||||
async def _wait_for_qr_login(self, account_id: str) -> None:
|
||||
data = self._get_auth_data(account_id)
|
||||
qr_login = data.get("qr_login")
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
qr_login = data.get("qr_login")
|
||||
if not qr_login:
|
||||
return
|
||||
try:
|
||||
@@ -631,7 +816,6 @@ class TelegramAuthManager:
|
||||
|
||||
async def _request_phone_code(self, account_id: str, phone: str) -> Dict[str, Any]:
|
||||
client = await self._get_client(account_id)
|
||||
data = self._get_auth_data(account_id)
|
||||
if await client.is_user_authorized():
|
||||
self._set_state(
|
||||
account_id,
|
||||
@@ -641,14 +825,13 @@ class TelegramAuthManager:
|
||||
)
|
||||
return self.auth_state(account_id)
|
||||
sent = await client.send_code_request(phone)
|
||||
data["phone"] = phone
|
||||
data["phone_code_hash"] = sent.phone_code_hash
|
||||
self._set_state(
|
||||
account_id,
|
||||
phase="code_required",
|
||||
status="code_required",
|
||||
details=f"Code sent to {phone}. Enter it below.",
|
||||
phone=phone,
|
||||
phone_code_hash=sent.phone_code_hash,
|
||||
)
|
||||
return self.auth_state(account_id)
|
||||
|
||||
@@ -657,14 +840,17 @@ class TelegramAuthManager:
|
||||
|
||||
async def _submit_phone_code(self, account_id: str, code: str) -> Dict[str, Any]:
|
||||
client = await self._get_client(account_id)
|
||||
data = self._get_auth_data(account_id)
|
||||
if not data.get("phone") or not data.get("phone_code_hash"):
|
||||
with self.lock:
|
||||
data = self._get_auth_data(account_id)
|
||||
phone = data.get("phone")
|
||||
phone_code_hash = data.get("phone_code_hash")
|
||||
if not phone or not phone_code_hash:
|
||||
raise RuntimeError("Request a phone code first.")
|
||||
try:
|
||||
await client.sign_in(
|
||||
phone=data["phone"],
|
||||
phone=phone,
|
||||
code=code,
|
||||
phone_code_hash=data["phone_code_hash"],
|
||||
phone_code_hash=phone_code_hash,
|
||||
)
|
||||
self._set_state(
|
||||
account_id,
|
||||
@@ -703,8 +889,9 @@ class TelegramAuthManager:
|
||||
return self._run(self._submit_password(account_id, password))
|
||||
|
||||
def delete_account(self, account_id: str) -> None:
|
||||
if account_id in self.clients:
|
||||
client = self.clients[account_id]
|
||||
with self.lock:
|
||||
client = self.clients.get(account_id)
|
||||
if client is not None:
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._disconnect_client(client), self.loop
|
||||
@@ -712,23 +899,27 @@ class TelegramAuthManager:
|
||||
future.result(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
del self.clients[account_id]
|
||||
self.auth_data.pop(account_id, None)
|
||||
with self.lock:
|
||||
self.clients.pop(account_id, None)
|
||||
self.auth_data.pop(account_id, None)
|
||||
|
||||
async def _disconnect_client(self, client: TelegramClient) -> None:
|
||||
if client and client.is_connected():
|
||||
await client.disconnect()
|
||||
|
||||
def shutdown(self, timeout: float = 5.0) -> None:
|
||||
with self.lock:
|
||||
clients = list(self.clients.values())
|
||||
|
||||
async def _disconnect_all():
|
||||
for client in self.clients.values():
|
||||
for client in clients:
|
||||
try:
|
||||
if client and client.is_connected():
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.clients:
|
||||
if clients:
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(_disconnect_all(), self.loop)
|
||||
future.result(timeout=timeout)
|
||||
@@ -799,13 +990,15 @@ class PerAccountContinuousScrapeManager:
|
||||
if str(item).strip()
|
||||
]
|
||||
self.config["run_all_tracked"] = bool(disk_cfg.get("run_all_tracked", True))
|
||||
# Sync running state with desired enabled state
|
||||
# Sync running state with desired enabled state. We only request a
|
||||
# stop here — the worker thread flips status["running"] to False in
|
||||
# its finally block only once it actually exits, so the status flag
|
||||
# never lies about a still-running scrape iteration.
|
||||
if self.config["enabled"] and not self.status["running"]:
|
||||
pass # don't auto-start — user must call start()
|
||||
elif not self.config["enabled"] and self.status["running"]:
|
||||
self._log("Continuous disabled via external state change, stopping.", "warn")
|
||||
self.stop_event.set()
|
||||
self.status["running"] = False
|
||||
self._log("Config refreshed from disk.", "debug")
|
||||
|
||||
def _log(self, message: str, level: str = "debug") -> None:
|
||||
@@ -848,7 +1041,10 @@ class PerAccountContinuousScrapeManager:
|
||||
"run_all_tracked": bool(run_all_tracked),
|
||||
}
|
||||
self._save_config()
|
||||
self.config = self._load_config()
|
||||
# Re-read from disk under the same lock so config assignment is
|
||||
# atomic w.r.t. refresh_config() (which also mutates config under
|
||||
# the lock), avoiding a torn read / write race.
|
||||
self.config = self._load_config()
|
||||
if enabled:
|
||||
self.start()
|
||||
else:
|
||||
@@ -864,17 +1060,36 @@ class PerAccountContinuousScrapeManager:
|
||||
self.status["running"] = True
|
||||
self.status["last_started_at"] = utc_now_iso()
|
||||
self.status["last_error"] = None
|
||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self.thread.start()
|
||||
self._log("Continuous scraping started.", "info")
|
||||
|
||||
def stop(self) -> None:
|
||||
# Idempotent: only the first call logs. We deliberately do NOT set
|
||||
# status["running"] = False here — the worker thread flips it once it
|
||||
# has actually exited (see _run_loop's finally), so status reflects
|
||||
# reality rather than intent.
|
||||
already_set = self.stop_event.is_set()
|
||||
self.stop_event.set()
|
||||
with self.lock:
|
||||
was_running = self.status["running"]
|
||||
self.status["running"] = False
|
||||
if was_running:
|
||||
self._log("Continuous scraping stop requested.", "warn")
|
||||
if not already_set:
|
||||
with self.lock:
|
||||
was_running = self.status["running"]
|
||||
if was_running:
|
||||
self._log("Continuous scraping stop requested.", "warn")
|
||||
|
||||
def join(self, timeout: float = 20.0) -> bool:
|
||||
"""Wait up to ``timeout`` seconds for the worker thread to exit.
|
||||
|
||||
Safe to call when the thread was never started or is already dead.
|
||||
Returns True if the thread finished within the timeout, False if it is
|
||||
still running (e.g. mid-scrape) and the caller should proceed
|
||||
best-effort.
|
||||
"""
|
||||
thread = self.thread
|
||||
if thread is None or not thread.is_alive():
|
||||
return True
|
||||
thread.join(timeout=timeout)
|
||||
return not thread.is_alive()
|
||||
|
||||
def _resolve_channels(self) -> List[str]:
|
||||
acc_state = load_account(DATA_DIR, self.account_id)
|
||||
@@ -887,66 +1102,73 @@ class PerAccountContinuousScrapeManager:
|
||||
return [channel for channel in configured if channel in tracked]
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
# Refresh config from disk so channel / setting changes take effect
|
||||
self.refresh_config()
|
||||
try:
|
||||
while not self.stop_event.is_set():
|
||||
# Refresh config from disk so channel / setting changes take effect
|
||||
self.refresh_config()
|
||||
# Bail promptly if refresh/cancel requested the stop so join()
|
||||
# usually returns quickly instead of waiting out a full scrape.
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
|
||||
# Check auth — don't iterate if account isn't authorized
|
||||
auth_info = auth_status_for(self.account_id)
|
||||
if auth_info.get("status") not in ("ready", "authorized"):
|
||||
self._log(
|
||||
f"Account not authorized (status={auth_info.get('status')}), "
|
||||
"skipping iteration",
|
||||
"warn",
|
||||
)
|
||||
sleep_seconds = max(5, 60)
|
||||
# Check auth — don't iterate if account isn't authorized
|
||||
auth_info = auth_status_for(self.account_id)
|
||||
if auth_info.get("status") not in ("ready", "authorized"):
|
||||
self._log(
|
||||
f"Account not authorized (status={auth_info.get('status')}), "
|
||||
"skipping iteration",
|
||||
"warn",
|
||||
)
|
||||
sleep_seconds = max(5, 60)
|
||||
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
||||
if interrupted:
|
||||
break
|
||||
continue
|
||||
|
||||
channels = self._resolve_channels()
|
||||
cfg = self.snapshot()["config"]
|
||||
interval_minutes = cfg.get("interval_minutes", 1)
|
||||
|
||||
if not channels:
|
||||
self._log("No channels configured for continuous scraping.", "warn")
|
||||
else:
|
||||
self._log(f"Starting iteration for {len(channels)} channel(s).", "info")
|
||||
buffer = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
|
||||
run_job("scrape_selected", {
|
||||
"channels": channels,
|
||||
"account_id": self.account_id,
|
||||
})
|
||||
output = buffer.getvalue().strip()
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self._log(line)
|
||||
with self.lock:
|
||||
self.status["last_iteration_at"] = utc_now_iso()
|
||||
self.status["last_finished_at"] = utc_now_iso()
|
||||
self.status["last_error"] = None
|
||||
self._log("Iteration finished.", "success")
|
||||
except Exception as exc:
|
||||
output = buffer.getvalue().strip()
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self._log(line)
|
||||
self._log(f"Iteration failed: {exc}", "error")
|
||||
with self.lock:
|
||||
self.status["last_error"] = str(exc)
|
||||
|
||||
sleep_seconds = max(5, interval_minutes * 60)
|
||||
self._log(f"Sleeping for {interval_minutes} minute(s).", "debug")
|
||||
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
||||
if interrupted:
|
||||
break
|
||||
continue
|
||||
|
||||
channels = self._resolve_channels()
|
||||
cfg = self.snapshot()["config"]
|
||||
interval_minutes = cfg.get("interval_minutes", 1)
|
||||
|
||||
if not channels:
|
||||
self._log("No channels configured for continuous scraping.", "warn")
|
||||
else:
|
||||
self._log(f"Starting iteration for {len(channels)} channel(s).", "info")
|
||||
buffer = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
|
||||
run_job("scrape_selected", {
|
||||
"channels": channels,
|
||||
"account_id": self.account_id,
|
||||
})
|
||||
output = buffer.getvalue().strip()
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self._log(line)
|
||||
with self.lock:
|
||||
self.status["last_iteration_at"] = utc_now_iso()
|
||||
self.status["last_finished_at"] = utc_now_iso()
|
||||
self.status["last_error"] = None
|
||||
self._log("Iteration finished.", "success")
|
||||
except Exception as exc:
|
||||
output = buffer.getvalue().strip()
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self._log(line)
|
||||
self._log(f"Iteration failed: {exc}", "error")
|
||||
with self.lock:
|
||||
self.status["last_error"] = str(exc)
|
||||
|
||||
sleep_seconds = max(5, interval_minutes * 60)
|
||||
self._log(f"Sleeping for {interval_minutes} minute(s).", "debug")
|
||||
interrupted = self.stop_event.wait(timeout=sleep_seconds)
|
||||
if interrupted:
|
||||
break
|
||||
|
||||
with self.lock:
|
||||
self.status["running"] = False
|
||||
self._log("Continuous scraping stopped.", "warn")
|
||||
finally:
|
||||
# Only mark running=False once the thread has truly exited so the
|
||||
# status reflects reality (a still-running scrape is not "stopped").
|
||||
with self.lock:
|
||||
self.status["running"] = False
|
||||
self._log("Continuous scraping stopped.", "warn")
|
||||
|
||||
|
||||
# ── ContinuousScrapeOrchestrator ─────────────────────────────────────────
|
||||
@@ -1012,7 +1234,22 @@ class ContinuousScrapeOrchestrator:
|
||||
self.start_account(account_id)
|
||||
|
||||
def remove_account(self, account_id: str) -> None:
|
||||
self.stop_account(account_id)
|
||||
with self.lock:
|
||||
mgr = self.managers.get(account_id)
|
||||
if mgr:
|
||||
mgr.stop()
|
||||
# Wait for the scrape thread to actually stop before the caller
|
||||
# deletes the account directory / session files, so rmtree does not
|
||||
# race with a writer mid-iteration. If the thread is still running
|
||||
# (e.g. mid-scrape) after the timeout we proceed best-effort and
|
||||
# log a warning.
|
||||
if not mgr.join(timeout=REMOVE_ACCOUNT_JOIN_TIMEOUT):
|
||||
logger.warning(
|
||||
"Continuous scrape thread for account %r still running after "
|
||||
"%.1fs; removing account data best-effort",
|
||||
account_id,
|
||||
REMOVE_ACCOUNT_JOIN_TIMEOUT,
|
||||
)
|
||||
with self.lock:
|
||||
self.managers.pop(account_id, None)
|
||||
|
||||
@@ -1841,6 +2078,13 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/jobs":
|
||||
return self.send_json(self.app.job_runner.recent_jobs())
|
||||
if path.startswith("/api/jobs/") and path.endswith("/events"):
|
||||
# SSE streams pin a worker thread up to 30 minutes, so reject
|
||||
# cross-site / cross-origin clients (e.g. <img> tags on other
|
||||
# pages) before opening the stream. Requests without an Origin /
|
||||
# Sec-Fetch-Site header (curl, same-origin EventSource that omits
|
||||
# it) are allowed - see _check_same_origin().
|
||||
if not self._check_same_origin():
|
||||
return
|
||||
job_id = path.split("/")[-2]
|
||||
return self.stream_job_events(job_id)
|
||||
if path.startswith("/api/jobs/"):
|
||||
@@ -1936,13 +2180,11 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
if sub == ["health"]:
|
||||
return self.send_json(account_health_summary(account_id, self.app.job_runner))
|
||||
if sub == ["export"]:
|
||||
include_secrets = query.get("include_secrets", ["0"])[0].lower() in {"1", "true", "yes"}
|
||||
payload = {
|
||||
return self.send_json({
|
||||
"version": 1,
|
||||
"account_id": account_id,
|
||||
"state": export_account_state(load_account(DATA_DIR, account_id), include_secrets=include_secrets),
|
||||
}
|
||||
return self.send_json(payload)
|
||||
"state": export_account_state(load_account(DATA_DIR, account_id)),
|
||||
})
|
||||
if sub == ["channels"]:
|
||||
return self._handle_get_account_channels(account_id)
|
||||
if len(sub) >= 3 and sub[0] == "channels" and sub[-1] == "messages":
|
||||
@@ -1978,29 +2220,37 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
def stream_job_events(self, job_id: str) -> None:
|
||||
if not self.app.job_runner.get_job(job_id):
|
||||
return self.send_error_json(HTTPStatus.NOT_FOUND, "Job not found")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.end_headers()
|
||||
runner = self.app.job_runner
|
||||
stream_id = runner.register_event_stream()
|
||||
try:
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.end_headers()
|
||||
|
||||
last_payload = None
|
||||
deadline = time.time() + 60 * 30
|
||||
while time.time() < deadline:
|
||||
job = self.app.job_runner.get_job(job_id)
|
||||
if not job:
|
||||
break
|
||||
payload = json.dumps(job, ensure_ascii=False)
|
||||
if payload != last_payload:
|
||||
try:
|
||||
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
last_payload = None
|
||||
deadline = time.time() + 60 * 30
|
||||
while time.time() < deadline:
|
||||
if runner.is_stream_revoked(stream_id):
|
||||
# Capped: this stream is the oldest and must close.
|
||||
break
|
||||
last_payload = payload
|
||||
if job.get("status") in {"completed", "failed"}:
|
||||
break
|
||||
time.sleep(1)
|
||||
job = runner.get_job(job_id)
|
||||
if not job:
|
||||
break
|
||||
payload = json.dumps(job, ensure_ascii=False)
|
||||
if payload != last_payload:
|
||||
try:
|
||||
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
break
|
||||
last_payload = payload
|
||||
if job.get("status") in TERMINAL_JOB_STATUSES:
|
||||
break
|
||||
time.sleep(1)
|
||||
finally:
|
||||
runner.unregister_event_stream(stream_id)
|
||||
|
||||
def _handle_get_account_channels(self, account_id: str) -> None:
|
||||
return self.send_json(list_channels_snapshot(account_id))
|
||||
@@ -2075,9 +2325,15 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
# ── POST ─────────────────────────────────────────────────────────────
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self._check_same_origin():
|
||||
return
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
body = self.read_json_body()
|
||||
if body is _JSON_CONTENT_TYPE_REJECTED:
|
||||
return self.send_error_json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "Content-Type must be application/json")
|
||||
if body is _JSON_BODY_TOO_LARGE:
|
||||
return self.send_error_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large")
|
||||
if body is None:
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, "Expected JSON body")
|
||||
|
||||
@@ -2097,11 +2353,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
enabled = bool(body.get("enabled"))
|
||||
interval_minutes = int(body.get("interval_minutes", 1))
|
||||
channels = [
|
||||
str(item).strip()
|
||||
for item in body.get("channels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
channels, dropped = clean_continuous_channels(body.get("channels", []))
|
||||
run_all_tracked = bool(body.get("run_all_tracked", True))
|
||||
if self.app.legacy_account_id:
|
||||
payload = self.app.continuous_orchestrator.update_for(
|
||||
@@ -2115,6 +2367,8 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
payload = {"config": {}, "status": {"running": False, "logs": [], "log_entries": []}}
|
||||
except Exception as exc:
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
if isinstance(payload, dict):
|
||||
payload["dropped_invalid"] = dropped
|
||||
return self.send_json(payload)
|
||||
|
||||
if path == "/api/auth/credentials":
|
||||
@@ -2421,33 +2675,62 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json(payload)
|
||||
|
||||
def _handle_post_account_auth_phone_request(self, account_id: str, body: Dict[str, Any]) -> None:
|
||||
ip = self.client_address[0] if self.client_address else ""
|
||||
if not _check_auth_throttle(ip, account_id):
|
||||
return self.send_error_json(
|
||||
HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"Too many failed attempts. Try again later.",
|
||||
)
|
||||
if not _check_auth_code_cooldown(ip, account_id):
|
||||
return self.send_error_json(
|
||||
HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"Please wait before requesting another code.",
|
||||
)
|
||||
phone = str(body.get("phone", "")).strip()
|
||||
if not phone:
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, "phone is required")
|
||||
try:
|
||||
payload = self.app.auth_manager.request_phone_code(account_id, phone)
|
||||
except Exception as exc:
|
||||
_record_auth_failure(ip, account_id)
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
_record_auth_code_request(ip, account_id)
|
||||
return self.send_json(payload)
|
||||
|
||||
def _handle_post_account_auth_phone_submit(self, account_id: str, body: Dict[str, Any]) -> None:
|
||||
ip = self.client_address[0] if self.client_address else ""
|
||||
if not _check_auth_throttle(ip, account_id):
|
||||
return self.send_error_json(
|
||||
HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"Too many failed attempts. Try again later.",
|
||||
)
|
||||
code = str(body.get("code", "")).strip()
|
||||
if not code:
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, "code is required")
|
||||
try:
|
||||
payload = self.app.auth_manager.submit_phone_code(account_id, code)
|
||||
except Exception as exc:
|
||||
_record_auth_failure(ip, account_id)
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
_record_auth_success(ip, account_id)
|
||||
return self.send_json(payload)
|
||||
|
||||
def _handle_post_account_auth_password(self, account_id: str, body: Dict[str, Any]) -> None:
|
||||
ip = self.client_address[0] if self.client_address else ""
|
||||
if not _check_auth_throttle(ip, account_id):
|
||||
return self.send_error_json(
|
||||
HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"Too many failed attempts. Try again later.",
|
||||
)
|
||||
password = str(body.get("password", "")).strip()
|
||||
if not password:
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, "password is required")
|
||||
try:
|
||||
payload = self.app.auth_manager.submit_password(account_id, password)
|
||||
except Exception as exc:
|
||||
_record_auth_failure(ip, account_id)
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
_record_auth_success(ip, account_id)
|
||||
return self.send_json(payload)
|
||||
|
||||
def _clean_imported_channels(self, channels: Any) -> Dict[str, Any]:
|
||||
@@ -2594,11 +2877,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
enabled = bool(body.get("enabled"))
|
||||
interval_minutes = int(body.get("interval_minutes", 1))
|
||||
channels = [
|
||||
str(item).strip()
|
||||
for item in body.get("channels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
channels, dropped = clean_continuous_channels(body.get("channels", []))
|
||||
run_all_tracked = bool(body.get("run_all_tracked", True))
|
||||
payload = self.app.continuous_orchestrator.update_for(
|
||||
account_id=account_id,
|
||||
@@ -2609,11 +2888,14 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
except Exception as exc:
|
||||
return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||
payload["dropped_invalid"] = dropped
|
||||
return self.send_json(payload)
|
||||
|
||||
# ── DELETE ───────────────────────────────────────────────────────────
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
if not self._check_same_origin():
|
||||
return
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
@@ -2664,16 +2946,49 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def read_json_body(self) -> Optional[Dict[str, Any]]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
def read_json_body(self) -> Any:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except (TypeError, ValueError):
|
||||
# Unparseable Content-Length - treat as no body rather than 500.
|
||||
length = 0
|
||||
if length <= 0:
|
||||
return {}
|
||||
if length > MAX_JSON_BODY_BYTES:
|
||||
return _JSON_BODY_TOO_LARGE
|
||||
content_type = self.headers.get("Content-Type", "")
|
||||
media_type = content_type.split(";", 1)[0].strip().lower()
|
||||
if media_type != "application/json":
|
||||
return _JSON_CONTENT_TYPE_REJECTED
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) > MAX_JSON_BODY_BYTES:
|
||||
return _JSON_BODY_TOO_LARGE
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
def _check_same_origin(self) -> bool:
|
||||
"""Reject state-mutating requests that are clearly cross-origin.
|
||||
|
||||
Returns True when the request may proceed. Requests without an
|
||||
Origin or Sec-Fetch-Site header are allowed (curl, same-origin
|
||||
browsers that omit the header). When present, the headers must not
|
||||
indicate a cross-origin request.
|
||||
"""
|
||||
host = self.headers.get("Host", "")
|
||||
origin = self.headers.get("Origin")
|
||||
if origin:
|
||||
origin_host = urllib.parse.urlparse(origin).netloc
|
||||
if not origin_host or (host and origin_host != host):
|
||||
self.send_error_json(HTTPStatus.FORBIDDEN, "Cross-origin request rejected")
|
||||
return False
|
||||
sec_fetch_site = self.headers.get("Sec-Fetch-Site", "").strip().lower()
|
||||
if sec_fetch_site and sec_fetch_site not in {"same-origin", "same-site", "none"}:
|
||||
self.send_error_json(HTTPStatus.FORBIDDEN, "Cross-origin request rejected")
|
||||
return False
|
||||
return True
|
||||
|
||||
def serve_static(self, relative_path: str, head_only: bool = False) -> None:
|
||||
file_path = (WEBUI_DIR / relative_path).resolve()
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user