import asyncio import base64 import contextlib import io import json import logging import mimetypes import os import posixpath import queue import signal import sqlite3 import threading import time import traceback import urllib.parse import uuid from dataclasses import dataclass, field from datetime import UTC, datetime from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any import qrcode import qrcode.image.svg from telethon import TelegramClient from telethon.errors import SessionPasswordNeededError from app_state import ( StateStore, account_data_dir, account_exists, account_session_path, get_account_store, get_global_store, list_accounts, load_account, ) from health import health_payload from scraper_jobs import ScraperJobService from telegram_scraper_with_forwarding import _ensure_session_wal BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" WEBUI_DIR = BASE_DIR / "webui" STATE_FILE = DATA_DIR / "state.json" DEFAULT_HOST = os.environ.get("TELEGRAM_SCRAPER_HOST", "0.0.0.0") DEFAULT_PORT = int(os.environ.get("TELEGRAM_SCRAPER_PORT", "8080")) START_CONTINUOUS = os.environ.get("TELEGRAM_SCRAPER_START_CONTINUOUS", "1") != "0" SESSION_DIR = BASE_DIR / "session" SESSION_DIR.mkdir(exist_ok=True) STATE_STORE = StateStore(STATE_FILE) 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() # ── M-3: boolean coercion helper ───────────────────────────────────────── _TRUE_VALUES = {"true", "1", "yes", "on"} _FALSE_VALUES = {"false", "0", "no", "off"} def parse_bool(value: Any, default: bool = False) -> bool: """Coerce *value* to bool safely. Returns *default* for unrecognised input. Recognises True/False, ``"true"``/``"false"``, ``"1"``/``"0"``, ``"yes"``/``"no"``, ``"on"``/``"off"`` (case-insensitive). Strings outside these sets return *default* instead of silently being truthy. """ if isinstance(value, bool): return value if value is None: return default s = str(value).strip().lower() if s in _TRUE_VALUES: return True if s in _FALSE_VALUES: return False return default # ── L-8: trusted-host check ────────────────────────────────────────────── import ipaddress # Comma-separated extra trusted hostnames for browser state-mutating # requests when the panel is served through a proxy domain. # Example: TRUSTED_HOSTS="tg.workstation.internal,example.com" _TRUSTED_HOSTS_ENV = frozenset( h.strip().lower().rstrip(".") for h in os.environ.get("TRUSTED_HOSTS", "").split(",") if h.strip() ) def _is_trusted_host(host: str) -> bool: """Return True if *host* (the ``Host`` header value) is a loopback / private address that this local-only deployment should trust.""" hostname = host.split("@")[-1].split(":")[0] # strip auth / port hostname = hostname.strip().lower().rstrip(".") if not hostname: return False if hostname in {"localhost", "127.0.0.1", "::1"}: return True if hostname in _TRUSTED_HOSTS_ENV: return True try: addr = ipaddress.ip_address(hostname) return addr.is_loopback or addr.is_private or addr.is_link_local except ValueError: return False # ── M-1: sensitive file / media extension allowlists ────────────────────── _SENSITIVE_FILE_SUFFIXES = frozenset({ ".db", ".session", ".db-wal", ".db-shm", ".db-journal", }) _SENSITIVE_FILE_NAMES = frozenset({"state.json"}) # How long a generated QR login token/image is considered valid before it is # dropped from the auth snapshot (seconds). L-5. QR_TTL_SECONDS = 60 _MEDIA_FILE_EXTENSIONS = frozenset({ # images ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".ico", # video ".mp4", ".webm", ".mov", ".m4v", ".avi", # audio ".mp3", ".ogg", ".wav", ".m4a", ".flac", # documents / generic downloaded binary ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv", ".json", ".xml", ".bin", # archives / other Telegram document types ".zip", ".rar", ".7z", ".apk", ".epub", ".tar", ".gz", ".bz2", ".xz", ".odt", ".ods", ".odp", # extended coverage (animated stickers, matroska, legacy containers) ".mkv", ".mk3d", ".heic", ".tgs", ".flv", ".3gp", ".ogv", ".asf", ".wmv", ".djvu", }) 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") return not (cooldown_until and now < cooldown_until) 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(UTC).isoformat() def load_state() -> dict[str, Any]: return STATE_STORE.load() def save_state(state: dict[str, Any]) -> None: STATE_STORE.save(state) def guess_media_kind(media_path: str | None, media_type: str | None) -> str | None: if not media_path: return None suffix = Path(media_path).suffix.lower() if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: return "image" if suffix in {".mp4", ".webm", ".mov", ".m4v"}: return "video" if suffix in {".mp3", ".ogg", ".wav", ".m4a"}: return "audio" if media_type == "MessageMediaPhoto": return "image" return "file" def normalize_media_url(media_path: str | None) -> str | None: if not media_path: return None path = Path(media_path) if path.is_absolute(): try: relative = path.resolve().relative_to(DATA_DIR.resolve()) except ValueError: return None else: normalized = Path(media_path) if normalized.parts and normalized.parts[0] == "data": normalized = Path(*normalized.parts[1:]) relative = normalized return "/media/" + urllib.parse.quote(str(relative).replace("\\", "/")) def normalize_channel_id(value: Any) -> str: channel_id = str(value or "").strip().lstrip("@") if ( not channel_id or "/" in channel_id or "\\" in channel_id or channel_id in {".", ".."} or any(ord(ch) < 32 for ch in channel_id) ): raise ValueError("channel_id contains unsupported path characters") return channel_id 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 "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 def channel_db_path(account_id: str | None, channel_id: str) -> Path: if account_id: return account_data_dir(DATA_DIR, account_id) / channel_id / f"{channel_id}.db" return DATA_DIR / channel_id / f"{channel_id}.db" def database_summary(account_id: str | None, channel_id: str) -> dict[str, Any]: db_path = channel_db_path(account_id, channel_id) summary: dict[str, Any] = { "message_count": 0, "last_date": None, "first_date": None, "media_count": 0, "has_database": db_path.exists(), "last_message_preview": "", "last_sender": "", } if not db_path.exists(): return summary conn = sqlite3.connect(str(db_path)) try: cursor = conn.cursor() cursor.execute( "SELECT COUNT(*), MIN(date), MAX(date), " "SUM(CASE WHEN media_type IS NOT NULL AND media_type != 'MessageMediaWebPage' THEN 1 ELSE 0 END) " "FROM messages" ) row = cursor.fetchone() or (0, None, None, 0) summary.update( { "message_count": row[0] or 0, "first_date": row[1], "last_date": row[2], "media_count": row[3] or 0, } ) try: cursor.execute( "SELECT message, media_type, first_name, last_name, username, post_author " "FROM messages ORDER BY message_id DESC LIMIT 1" ) latest = cursor.fetchone() except sqlite3.OperationalError: latest = None if latest: text = (latest[0] or "").strip() if not text and latest[1]: text = f"[{latest[1]}]" sender = " ".join(part for part in [latest[2], latest[3]] if part).strip() summary["last_message_preview"] = text[:180] summary["last_sender"] = sender or latest[4] or latest[5] or "" return summary finally: conn.close() def channel_display_name(state: dict[str, Any], channel_id: str) -> str: return state.get("channel_names", {}).get(channel_id) or channel_id def list_channels_snapshot(account_id: str | None = None) -> list[dict[str, Any]]: if account_id: state = load_account(DATA_DIR, account_id) else: state = load_state() channels: list[dict[str, Any]] = [] for channel_id, last_message_id in state.get("channels", {}).items(): summary = database_summary(account_id, channel_id) channels.append( { "channel_id": channel_id, "name": channel_display_name(state, channel_id), "last_message_id": last_message_id, **summary, } ) channels.sort( key=lambda item: (item["last_date"] or "", item["message_count"]), reverse=True, ) return channels def account_state_for_legacy() -> dict[str, Any] | None: legacy = load_state() if legacy.get("api_id") and legacy.get("api_hash"): return legacy return None def load_messages( account_id: str | None, channel_id: str, limit: int = 120, before_message_id: int | None = None, search: str | None = None, ) -> list[dict[str, Any]]: db_path = channel_db_path(account_id, channel_id) if not db_path.exists(): return [] conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row try: query = ( "SELECT message_id, date, sender_id, first_name, last_name, username, message, " "media_type, media_path, reply_to, post_author, views, forwards, reactions " "FROM messages " ) params: list[Any] = [] where: list[str] = [] if before_message_id is not None: where.append("message_id < ?") params.append(before_message_id) if search: where.append("message LIKE ?") params.append(f"%{search}%") if where: query += "WHERE " + " AND ".join(where) + " " query += "ORDER BY message_id DESC LIMIT ?" params.append(limit) rows = conn.execute(query, params).fetchall() finally: conn.close() messages: list[dict[str, Any]] = [] for row in reversed(rows): sender_name = " ".join( part for part in [row["first_name"], row["last_name"]] if part ).strip() if not sender_name: sender_name = row["username"] or row["post_author"] or channel_id messages.append( { "message_id": row["message_id"], "date": row["date"], "sender_id": row["sender_id"], "sender_name": sender_name, "username": row["username"], "text": row["message"] or "", "media_type": row["media_type"], "media_path": row["media_path"], "media_url": normalize_media_url(row["media_path"]), "media_kind": guess_media_kind(row["media_path"], row["media_type"]), "reply_to": row["reply_to"], "post_author": row["post_author"], "views": row["views"], "forwards": row["forwards"], "reactions": row["reactions"], } ) reply_ids = list({m["reply_to"] for m in messages if m["reply_to"]}) if reply_ids: placeholders = ",".join("?" for _ in reply_ids) try: conn2 = sqlite3.connect(str(db_path)) conn2.row_factory = sqlite3.Row try: rows2 = conn2.execute( f"SELECT message_id, message, first_name, last_name, username " f"FROM messages WHERE message_id IN ({placeholders})", reply_ids, ).fetchall() reply_map: dict[int, dict[str, str]] = {} for r in rows2: r_sender = " ".join( part for part in [r["first_name"], r["last_name"]] if part ).strip() or (r["username"] or "") reply_map[r["message_id"]] = { "text": (r["message"] or "")[:300], "sender_name": r_sender, } for m in messages: rt = m.get("reply_to") if rt and rt in reply_map: m["reply_to_message"] = reply_map[rt] finally: conn2.close() except sqlite3.Error: pass return messages # ── Job ────────────────────────────────────────────────────────────────── @dataclass class Job: job_id: str job_type: str title: str payload: dict[str, Any] status: str = "queued" created_at: str = field(default_factory=utc_now_iso) started_at: str | None = None finished_at: str | None = None logs: str = "" error: str | None = None account_id: str | None = None def to_dict(self) -> dict[str, Any]: return { "job_id": self.job_id, "job_type": self.job_type, "title": self.title, "payload": self.payload, "status": self.status, "created_at": self.created_at, "started_at": self.started_at, "finished_at": self.finished_at, "logs": self.logs, "error": self.error, "account_id": self.account_id, } # ── JobRunner ──────────────────────────────────────────────────────────── class JobRunner: def __init__(self) -> None: self.jobs: dict[str, Job] = {} self.job_order: list[str] = [] 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") account_id = payload.get("account_id") with self.lock: if account_id: for job_id in self.job_order: existing = self.jobs[job_id] if ( existing.account_id == account_id and existing.status in {"queued", "running"} ): existing.logs = ( existing.logs + "\n" + f"[{datetime.now().strftime('%H:%M:%S')}] Reused existing active job for this account." ).strip() return existing job_id = f"job-{uuid.uuid4().hex[:12]}" job = Job( job_id=job_id, job_type=job_type, title=title, payload=payload, account_id=account_id, ) self.jobs[job_id] = job self.job_order.insert(0, job_id) self.job_order = self.job_order[:20] self.queue.put(job) return job def recent_jobs(self, account_id: str | None = None) -> list[dict[str, Any]]: with self.lock: result = [self.jobs[job_id].to_dict() for job_id in self.job_order] if account_id is not None: result = [j for j in result if j.get("account_id") == account_id] return result def active_jobs_by_account(self) -> dict[str, dict[str, Any]]: with self.lock: active = {} for job_id in self.job_order: job = self.jobs[job_id] if job.account_id and job.status in {"queued", "running"}: active[job.account_id] = job.to_dict() return active def get_job(self, job_id: str) -> dict[str, Any] | None: with self.lock: job = self.jobs.get(job_id) return job.to_dict() if job else None def shutdown(self, timeout: float = 10.0) -> None: self._shutdown_flag = True running_jobs = [] with self.lock: for job_id, job in list(self.jobs.items()): if job.status == "running": running_jobs.append(job) if running_jobs: logger.warning("Waiting for %d running job(s) to finish...", len(running_jobs)) deadline = time.time() + timeout while time.time() < deadline: with self.lock: still_running = [j for j in running_jobs if j.status == "running"] if not still_running: break time.sleep(0.2) still_running = [j for j in running_jobs if j.status == "running"] if still_running: logger.warning( "%d job(s) did not finish within %ss timeout", len(still_running), timeout, ) # M-7: The worker exits once _shutdown_flag is set, leaving any yet-to-be # processed queued jobs stranded as "queued". Drain the queue and mark # each leftover as failed so their event streams / polling terminate. drained = 0 while True: try: job = self.queue.get_nowait() except queue.Empty: break self.queue.task_done() with self.lock: job.status = "failed" job.error = "Server shutting down; job was cancelled" job.finished_at = utc_now_iso() drained += 1 if drained: logger.info("Failed %d queued job(s) during shutdown.", drained) def _run(self) -> None: while not self._shutdown_flag: try: job = self.queue.get(timeout=1) except queue.Empty: continue self._execute(job) self.queue.task_done() def _execute(self, job: Job) -> None: with self.lock: job.status = "running" job.started_at = utc_now_iso() buffer = io.StringIO() try: with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer): run_job(job.job_type, job.payload) with self.lock: job.status = "done" except Exception as exc: with self.lock: job.status = "failed" job.error = str(exc) traceback.print_exc(file=buffer) finally: with self.lock: job.logs = buffer.getvalue() job.finished_at = utc_now_iso() # ── TelegramAuthManager (per-account) ──────────────────────────────────── class TelegramAuthManager: def __init__(self) -> None: # 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() self.clients: dict[str, TelegramClient] = {} self.auth_data: dict[str, dict[str, Any]] = {} def _run_loop(self) -> None: asyncio.set_event_loop(self.loop) self.loop.run_forever() def _run(self, coro): future = asyncio.run_coroutine_threadsafe(coro, self.loop) return future.result() def _get_auth_data(self, account_id: str) -> dict[str, Any]: 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, "qr_created_at": 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: 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) qr.add_data(qr_url) qr.make(fit=True) image = qr.make_image(image_factory=qrcode.image.svg.SvgPathImage) buffer = io.BytesIO() image.save(buffer) encoded = base64.b64encode(buffer.getvalue()).decode("ascii") return f"data:image/svg+xml;base64,{encoded}" async def _get_client(self, account_id: str) -> TelegramClient: acc_state = load_account(DATA_DIR, account_id) api_id = acc_state.get("api_id") 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.") 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() 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() except Exception: 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]: with self.lock: data = self._get_auth_data(account_id) snapshot = dict(data) # L-5: expire a QR login token that has not been scanned within # QR_TTL_SECONDS so it cannot live forever in the auth snapshot. qr_created = data.get("qr_created_at") if qr_created and (time.time() - float(qr_created)) > QR_TTL_SECONDS: if data.get("qr_url") or data.get("qr_image"): data["qr_url"] = None data["qr_image"] = None data["qr_created_at"] = None snapshot = dict(data) snapshot["qr_expired"] = True 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")), } snapshot["auth_status"] = auth_status_for(account_id) return snapshot def all_auth_states(self) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for account_id in list_accounts(DATA_DIR): result[account_id] = self.auth_state(account_id) return result def save_credentials(self, account_id: str, api_id: int, api_hash: str) -> dict[str, Any]: def mutate(state: dict[str, Any]) -> None: state["api_id"] = int(api_id) state["api_hash"] = api_hash.strip() store = get_account_store(DATA_DIR, account_id) store.update(mutate) self._set_state( account_id, phase="credentials_saved", status="ready_for_auth", details="Credentials saved. You can login with QR or phone code.", ) return self.auth_state(account_id) async def _start_qr_login(self, account_id: str) -> dict[str, Any]: client = await self._get_client(account_id) if await client.is_user_authorized(): self._set_state( account_id, phase="authorized", status="authorized", details="Telegram session is already authorized.", ) return self.auth_state(account_id) qr_login = await client.qr_login() qr_url = qr_login.url 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", status="qr_waiting", details="Scan the QR code in Telegram: Settings -> Devices -> Scan QR.", qr_url=qr_url, qr_image=self._make_qr_image(qr_url), qr_created_at=time.time(), ) 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: with self.lock: data = self._get_auth_data(account_id) qr_login = data.get("qr_login") if not qr_login: return try: await qr_login.wait() self._set_state( account_id, phase="authorized", status="authorized", details="Telegram session is authorized.", qr_url=None, qr_image=None, qr_created_at=None, phone=None, ) except SessionPasswordNeededError: self._set_state( account_id, phase="password_required", status="password_required", details="Two-factor authentication is enabled. Enter your Telegram password.", ) except Exception as exc: self._set_state( account_id, phase="error", status="error", details=f"QR login failed: {exc}", qr_url=None, qr_image=None, qr_created_at=None, ) def start_qr_login(self, account_id: str) -> dict[str, Any]: return self._run(self._start_qr_login(account_id)) async def _request_phone_code(self, account_id: str, phone: str) -> dict[str, Any]: client = await self._get_client(account_id) if await client.is_user_authorized(): self._set_state( account_id, phase="authorized", status="authorized", details="Telegram session is already authorized.", ) return self.auth_state(account_id) sent = await client.send_code_request(phone) 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) def request_phone_code(self, account_id: str, phone: str) -> dict[str, Any]: return self._run(self._request_phone_code(account_id, phone)) async def _submit_phone_code(self, account_id: str, code: str) -> dict[str, Any]: client = await self._get_client(account_id) 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=phone, code=code, phone_code_hash=phone_code_hash, ) self._set_state( account_id, phase="authorized", status="authorized", details="Telegram session is authorized.", qr_url=None, qr_image=None, qr_created_at=None, ) except SessionPasswordNeededError: self._set_state( account_id, phase="password_required", status="password_required", details="Two-factor authentication is enabled. Enter your Telegram password.", ) return self.auth_state(account_id) def submit_phone_code(self, account_id: str, code: str) -> dict[str, Any]: return self._run(self._submit_phone_code(account_id, code)) async def _submit_password(self, account_id: str, password: str) -> dict[str, Any]: client = await self._get_client(account_id) await client.sign_in(password=password) self._set_state( account_id, phase="authorized", status="authorized", details="Telegram session is authorized.", qr_url=None, qr_image=None, qr_created_at=None, ) return self.auth_state(account_id) def submit_password(self, account_id: str, password: str) -> dict[str, Any]: return self._run(self._submit_password(account_id, password)) def delete_account(self, account_id: str) -> None: 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 ) future.result(timeout=5) except Exception: pass 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 clients: try: if client and client.is_connected(): await client.disconnect() except Exception: pass if clients: try: future = asyncio.run_coroutine_threadsafe(_disconnect_all(), self.loop) future.result(timeout=timeout) except Exception: pass self.loop.call_soon_threadsafe(self.loop.stop) self.thread.join(timeout=timeout) # ── PerAccountContinuousScrapeManager ──────────────────────────────────── class PerAccountContinuousScrapeManager: def __init__(self, account_id: str) -> None: self.account_id = account_id self.lock = threading.RLock() self.thread: threading.Thread | None = None self.stop_event = threading.Event() # F-4: set on remove_account when its thread could not be joined in # time; the manager is kept as a tombstone so it is reused (after the # drain) instead of spawning a duplicate worker on account re-add. self._removing = False self.config: dict[str, Any] = self._load_config() self.status: dict[str, Any] = { "running": False, "last_started_at": None, "last_finished_at": None, "last_iteration_at": None, "last_error": None, "logs": [], "log_entries": [], } def _load_config(self) -> dict[str, Any]: acc_state = load_account(DATA_DIR, self.account_id) cfg = dict(acc_state.get("continuous_scraping", { "enabled": True, "interval_minutes": 1, "channels": [], "run_all_tracked": True, })) # F-6: normalize @-prefixed channel values that were written to disk # by import/migration/older code — they must match tracked ids (no @). cfg["channels"] = [ str(ch).strip().lstrip("@") for ch in cfg.get("channels", []) if str(ch).strip() ] return cfg def _save_config(self) -> None: store = get_account_store(DATA_DIR, self.account_id) def mutate(state: dict[str, Any]) -> None: state["continuous_scraping"] = { "enabled": bool(self.config.get("enabled", True)), "interval_minutes": max(1, int(self.config.get("interval_minutes", 1) or 1)), "channels": [ str(item).strip().lstrip("@") for item in self.config.get("channels", []) if str(item).strip() ], "run_all_tracked": bool(self.config.get("run_all_tracked", True)), } store.update(mutate) def refresh_config(self) -> None: """ Re-read account state from disk and sync the in-memory config. Call this after external state changes (import, channel add/remove) so the manager picks up new channels or config updates without a restart. """ disk_state = load_account(DATA_DIR, self.account_id) disk_cfg = disk_state.get("continuous_scraping") or {} with self.lock: # Merge disk config into memory, preserving our current running state self.config["enabled"] = bool(disk_cfg.get("enabled", False)) self.config["interval_minutes"] = max(1, int(disk_cfg.get("interval_minutes", 1) or 1)) self.config["channels"] = [ str(item).strip().lstrip("@") for item in disk_cfg.get("channels", []) if str(item).strip() ] self.config["run_all_tracked"] = bool(disk_cfg.get("run_all_tracked", True)) # 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._log("Config refreshed from disk.", "debug") def _log(self, message: str, level: str = "debug") -> None: timestamp = utc_now_iso() entry = {"timestamp": timestamp, "level": level, "message": message} with self.lock: logs = self.status.setdefault("logs", []) log_entries = self.status.setdefault("log_entries", []) logs.append(f"[{datetime.now().strftime('%H:%M:%S')}] {message}") log_entries.append(entry) if len(logs) > 300: del logs[:-300] if len(log_entries) > 300: del log_entries[:-300] def snapshot(self) -> dict[str, Any]: with self.lock: return { "config": dict(self.config), "status": { **self.status, "logs": list(self.status.get("logs", [])), "log_entries": list(self.status.get("log_entries", [])), }, } def update( self, enabled: bool, interval_minutes: int, channels: list[str], run_all_tracked: bool, ) -> dict[str, Any]: interval_minutes = max(1, int(interval_minutes)) with self.lock: self.config = { "enabled": bool(enabled), "interval_minutes": interval_minutes, "channels": channels, "run_all_tracked": bool(run_all_tracked), } self._save_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: self.stop() return self.snapshot() def start(self) -> None: with self.lock: thread = self.thread if thread is not None and thread.is_alive(): if self.stop_event.is_set(): # F-3: a stop was requested and the old thread is still # draining (its finally has not yet flipped running=False). # Wait for it to exit before starting a fresh one so the # account does not end up enabled=True with a dead thread. self._log( "Previous scrape thread is stopping; waiting before restart.", "warn", ) else: # A live thread is genuinely running — do NOT spawn a # duplicate. self._log("Continuous scraping is already running.", "warn") return if thread is not None and thread.is_alive(): # Join outside the lock (bounded) so the draining thread can mark # itself finished. If it does not exit in time, refuse to start a # duplicate worker rather than risk overlapping writes. thread.join(timeout=5.0) if thread.is_alive(): self._log( "Previous scrape thread still stopping; cannot restart yet.", "error", ) return with self.lock: self.stop_event.clear() 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.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() 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) with self.lock: run_all_tracked = self.config.get("run_all_tracked", True) configured = list(self.config.get("channels", [])) if run_all_tracked: return list(acc_state.get("channels", {}).keys()) tracked = set(acc_state.get("channels", {}).keys()) return [channel for channel in configured if channel in tracked] def _run_loop(self) -> None: try: while not self.stop_event.is_set(): # F-7: a top-level guard keeps the worker thread alive even if # something outside the scrape iteration itself raises (e.g. # refresh_config/auth_status_for). A dead worker thread while # enabled=True is the failure mode F-3 was fixing. try: # 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) 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 except Exception: # Unexpected failure outside the scrape iteration: log it, # surface it in status, and back off briefly instead of # letting the worker thread die silently. logger.exception("Continuous scrape loop error for account %s", self.account_id) with self.lock: self.status["last_error"] = "Unexpected loop error (see logs)" self.status["last_iteration_at"] = utc_now_iso() self._log("Unexpected loop error; retrying in 10s.", "error") interrupted = self.stop_event.wait(timeout=10) if interrupted: break 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 ───────────────────────────────────────── class ContinuousScrapeOrchestrator: def __init__(self) -> None: self.lock = threading.Lock() self.managers: dict[str, PerAccountContinuousScrapeManager] = {} def _get_or_create(self, account_id: str) -> PerAccountContinuousScrapeManager: with self.lock: mgr = self.managers.get(account_id) if mgr is None: mgr = PerAccountContinuousScrapeManager(account_id) self.managers[account_id] = mgr elif mgr._removing: # F-4: the manager was left as a tombstone because its worker # could not be joined during remove_account. The account is # being re-added / accessed, so clear the tombstone and reuse # the manager (start() waits for the old thread to drain before # spawning a fresh one, avoiding duplicate workers). mgr._removing = False return mgr def start_account(self, account_id: str) -> None: mgr = self._get_or_create(account_id) mgr.start() def stop_account(self, account_id: str) -> None: with self.lock: mgr = self.managers.get(account_id) if mgr: mgr.stop() def snapshot_for(self, account_id: str) -> dict[str, Any]: mgr = self._get_or_create(account_id) return mgr.snapshot() def snapshot(self) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} with self.lock: for account_id, mgr in self.managers.items(): result[account_id] = dict(mgr.snapshot()) missing = set(list_accounts(DATA_DIR)) - set(result.keys()) for account_id in missing: mgr = self._get_or_create(account_id) result[account_id] = dict(mgr.snapshot()) return result def refresh_account(self, account_id: str) -> None: """Re-read account state from disk and sync the continuous manager config.""" mgr = self._get_or_create(account_id) mgr.refresh_config() def update_for( self, account_id: str, enabled: bool, interval_minutes: int, channels: list[str], run_all_tracked: bool, ) -> dict[str, Any]: mgr = self._get_or_create(account_id) result = mgr.update(enabled, interval_minutes, channels, run_all_tracked) # After explicit update, sync state to disk is already done by mgr.update return result def add_account(self, account_id: str, auto_start: bool = True) -> None: acc_state = load_account(DATA_DIR, account_id) cfg = acc_state.get("continuous_scraping", {}) if auto_start and cfg.get("enabled", False) and START_CONTINUOUS: self.start_account(account_id) def remove_account(self, account_id: str) -> None: 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 mgr.join(timeout=REMOVE_ACCOUNT_JOIN_TIMEOUT): # Thread finished: safe to drop the manager. with self.lock: self.managers.pop(account_id, None) else: # F-4: the thread is still draining. Do NOT pop the manager — # leave it as a tombstone (marked removing) so a later re-add of # the same account id reuses it / waits for the drain instead of # spawning a duplicate worker that writes the same dirs. logger.warning( "Continuous scrape thread for account %r still running after " "%.1fs; keeping manager as tombstone", account_id, REMOVE_ACCOUNT_JOIN_TIMEOUT, ) with self.lock: mgr._removing = True def stop_all(self) -> None: with self.lock: for account_id in list(self.managers.keys()): self.managers[account_id].stop() def start_all(self) -> None: for account_id in list_accounts(DATA_DIR): acc_state = load_account(DATA_DIR, account_id) cfg = acc_state.get("continuous_scraping", {}) if cfg.get("enabled", False) and START_CONTINUOUS: self.start_account(account_id) # ── Legacy helpers ─────────────────────────────────────────────────────── def import_scraper_class(): from telegram_scraper_with_forwarding import OptimizedTelegramScraper return OptimizedTelegramScraper def run_job(job_type: str, payload: dict[str, Any]) -> None: SCRAPER_JOBS.run(job_type, payload) def auth_status_for(account_id: str | None = None) -> dict[str, Any]: if account_id: acc_state = load_account(DATA_DIR, account_id) session_file = account_session_path(SESSION_DIR, account_id) else: acc_state = load_state() session_file = str(SESSION_DIR / "session.session") status: dict[str, Any] = { "has_api_credentials": bool(acc_state.get("api_id") and acc_state.get("api_hash")), "telethon_available": False, "session_ready": False, "status": "read_only", "details": "", } try: import_scraper_class() status["telethon_available"] = True except Exception as exc: status["details"] = f"Python deps are missing: {exc}" return status status["session_ready"] = Path(session_file).exists() if status["has_api_credentials"] and status["session_ready"]: status["status"] = "ready" status["details"] = "Scraping actions should be available after dependencies are installed." elif status["has_api_credentials"]: status["status"] = "needs_auth" status["details"] = "API keys found, but Telegram session file is missing." else: status["status"] = "needs_credentials" status["details"] = "api_id/api_hash are missing." return status def account_health_summary(account_id: str, job_runner: JobRunner | None = None) -> dict[str, Any]: acc_state = load_account(DATA_DIR, account_id) acc_dir = account_data_dir(DATA_DIR, account_id) session_file = Path(account_session_path(SESSION_DIR, account_id)) channels = list_channels_snapshot(account_id) active_job = (job_runner.active_jobs_by_account().get(account_id) if job_runner else None) last_scrape = next((item.get("last_date") for item in channels if item.get("last_date")), None) return { "account_id": account_id, "label": acc_state.get("label", ""), "data_dir_exists": acc_dir.exists(), "session_ready": session_file.exists(), "api_credentials": bool(acc_state.get("api_id") and acc_state.get("api_hash")), "channel_count": len(channels), "message_count": sum(int(item.get("message_count") or 0) for item in channels), "media_count": sum(int(item.get("media_count") or 0) for item in channels), "last_scrape": last_scrape, "active_job": active_job, } def auth_status() -> dict[str, Any]: return auth_status_for(account_id=None) def dashboard_payload(job_runner: JobRunner, account_id: str | None = None) -> dict[str, Any]: if account_id: acc_state = load_account(DATA_DIR, account_id) state = { "scrape_media": bool(acc_state.get("scrape_media", True)), "channel_count": len(acc_state.get("channels", {})), "forwarding_rules": acc_state.get("forwarding_rules", []), } channels = list_channels_snapshot(account_id) auth = auth_status_for(account_id) jobs = job_runner.recent_jobs(account_id=account_id) else: state = load_state() channels = list_channels_snapshot() auth = auth_status() jobs = job_runner.recent_jobs() return { "state": { "scrape_media": bool(state.get("scrape_media", True)), "channel_count": len(state.get("channels", {})), "forwarding_rules": state.get("forwarding_rules", []), }, "auth": auth, "channels": channels, "jobs": jobs, } def openapi_payload() -> dict[str, Any]: json_response = { "200": { "description": "JSON response", "content": {"application/json": {"schema": {"type": "object"}}}, } } accepted_response = { "202": { "description": "Job accepted", "content": {"application/json": {"schema": {"type": "object"}}}, } } error_response = {"400": {"description": "Bad request"}} def json_body(properties: dict[str, Any], required: list[str] | None = None): return { "required": True, "content": { "application/json": { "schema": { "type": "object", "properties": properties, "required": required or [], "example": { key: value.get("example") for key, value in properties.items() if "example" in value }, } } }, } return { "openapi": "3.0.3", "info": { "title": "Telegram Scraper Web UI API", "version": "0.1.0", "description": "Local endpoints used by the Telegram Scraper web interface.", }, "servers": [{"url": "/"}], "paths": { "/api/dashboard": { "get": { "summary": "Dashboard snapshot", "description": "Returns state summary, channels, auth status, and recent jobs.", "responses": json_response, } }, "/api/auth": { "get": { "summary": "Authentication status", "description": "Returns saved credential flags, login phase, and QR login data.", "responses": json_response, } }, "/api/auth/credentials": { "post": { "summary": "Save Telegram API credentials", "requestBody": json_body( { "api_id": {"type": "integer", "example": 123456}, "api_hash": { "type": "string", "example": "0123456789abcdef", }, }, ["api_id", "api_hash"], ), "responses": {**json_response, **error_response}, } }, "/api/auth/qr/start": { "post": { "summary": "Start QR login", "requestBody": json_body({}), "responses": {**json_response, **error_response}, } }, "/api/auth/phone/request": { "post": { "summary": "Request phone login code", "requestBody": json_body( {"phone": {"type": "string", "example": "+1234567890"}}, ["phone"], ), "responses": {**json_response, **error_response}, } }, "/api/auth/phone/submit": { "post": { "summary": "Submit phone login code", "requestBody": json_body( {"code": {"type": "string", "example": "12345"}}, ["code"], ), "responses": {**json_response, **error_response}, } }, "/api/auth/password": { "post": { "summary": "Submit 2FA password", "requestBody": json_body( {"password": {"type": "string", "example": "password"}}, ["password"], ), "responses": {**json_response, **error_response}, } }, "/api/continuous": { "get": { "summary": "Continuous scraping status", "responses": json_response, }, "post": { "summary": "Update continuous scraping", "requestBody": json_body( { "enabled": {"type": "boolean", "example": True}, "interval_minutes": {"type": "integer", "example": 5}, "run_all_tracked": {"type": "boolean", "example": False}, "channels": { "type": "array", "items": {"type": "string"}, "example": ["-1001234567890"], }, } ), "responses": {**json_response, **error_response}, }, }, "/health/continuous": { "get": { "summary": "Continuous scraping health", "responses": json_response, } }, "/health": { "get": { "summary": "Application health", "responses": json_response, } }, "/api/channels": { "get": { "summary": "List tracked channels", "responses": json_response, } }, "/api/channels/{channel_id}/messages": { "get": { "summary": "List channel messages", "parameters": [ { "name": "channel_id", "in": "path", "required": True, "schema": {"type": "string"}, }, { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 120, "maximum": 300, }, }, { "name": "before", "in": "query", "schema": {"type": "integer"}, }, { "name": "search", "in": "query", "schema": {"type": "string"}, "description": "Full-text search in message text", }, ], "responses": json_response, } }, "/api/channels/add": { "post": { "summary": "Track a channel", "requestBody": json_body( { "channel_id": { "type": "string", "example": "-1001234567890", }, "name": {"type": "string", "example": "Research feed"}, }, ["channel_id"], ), "responses": {**json_response, **error_response}, } }, "/api/channels/remove": { "post": { "summary": "Remove a tracked channel", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**json_response, **error_response}, } }, "/api/settings/media": { "post": { "summary": "Toggle media scraping", "requestBody": json_body( {"value": {"type": "boolean", "example": True}}, ["value"], ), "responses": {**accepted_response, **error_response}, } }, "/api/jobs": { "get": { "summary": "Recent jobs", "responses": json_response, } }, "/api/jobs/{job_id}": { "get": { "summary": "Job details", "parameters": [ { "name": "job_id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": { **json_response, "404": {"description": "Job not found"}, }, } }, "/api/jobs/{job_id}/events": { "get": { "summary": "Job SSE event stream", "description": "Server-Sent Events stream of job status updates. Closes when job completes or fails.", "parameters": [ { "name": "job_id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": { "200": { "description": "SSE stream of job status objects", "content": { "text/event-stream": { "schema": {"type": "string"} } }, }, "404": {"description": "Job not found"}, }, } }, "/api/jobs/scrape": { "post": { "summary": "Scrape one channel or all tracked channels", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}} ), "responses": accepted_response, } }, "/api/jobs/export": { "post": { "summary": "Export all tracked channels", "requestBody": json_body({}), "responses": accepted_response, } }, "/api/jobs/export-channel": { "post": { "summary": "Export one channel", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**accepted_response, **error_response}, } }, "/api/jobs/rescrape-media": { "post": { "summary": "Rescrape media for a channel", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**accepted_response, **error_response}, } }, "/api/jobs/fix-media": { "post": { "summary": "Fix missing media for a channel", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**accepted_response, **error_response}, } }, "/api/jobs/refresh-dialogs": { "post": { "summary": "Refresh Telegram dialogs", "requestBody": json_body({}), "responses": accepted_response, } }, "/openapi.json": { "get": { "summary": "OpenAPI document", "responses": json_response, } }, "/api/accounts": { "get": { "summary": "List all accounts", "responses": json_response, }, "post": { "summary": "Create a new account", "requestBody": json_body( { "account_id": { "type": "string", "example": "work", }, "label": { "type": "string", "example": "Work Account", }, "api_id": { "type": "integer", "example": 123456, }, "api_hash": { "type": "string", "example": "0123456789abcdef", }, } ), "responses": {**json_response, **error_response}, }, }, "/api/accounts/import": { "post": { "summary": "Import account from exported JSON", "requestBody": json_body( { "account_id": {"type": "string", "example": "work"}, "state": { "type": "object", "description": "Exported account state object", "example": {"label": "Work", "api_id": 123456, "api_hash": "abc", "channels": {}, "continuous_scraping": {"enabled": False}}, }, }, ["account_id", "state"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}": { "get": { "summary": "Account dashboard", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, }, "delete": { "summary": "Delete account", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, }, }, "/api/accounts/{id}/export": { "get": { "summary": "Export account settings as JSON", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, } }, "/api/accounts/{id}/health": { "get": { "summary": "Account health summary", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, } }, "/api/accounts/{id}/auth": { "get": { "summary": "Account auth snapshot", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, }, }, "/api/accounts/{id}/auth/credentials": { "post": { "summary": "Save credentials for account", "requestBody": json_body( { "api_id": {"type": "integer", "example": 123456}, "api_hash": {"type": "string", "example": "0123456789abcdef"}, }, ["api_id", "api_hash"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/auth/qr/start": { "post": { "summary": "Start QR login for account", "requestBody": json_body({}), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/auth/phone/request": { "post": { "summary": "Request phone code for account", "requestBody": json_body( {"phone": {"type": "string", "example": "+1234567890"}}, ["phone"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/auth/phone/submit": { "post": { "summary": "Submit phone code for account", "requestBody": json_body( {"code": {"type": "string", "example": "12345"}}, ["code"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/auth/password": { "post": { "summary": "Submit 2FA password for account", "requestBody": json_body( {"password": {"type": "string", "example": "password"}}, ["password"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/channels": { "get": { "summary": "List channels for account", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, } }, "/api/accounts/{id}/channels/{channel_id}/messages": { "get": { "summary": "List messages for account channel", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, }, { "name": "channel_id", "in": "path", "required": True, "schema": {"type": "string"}, }, { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 120, "maximum": 300, }, }, { "name": "before", "in": "query", "schema": {"type": "integer"}, }, { "name": "search", "in": "query", "schema": {"type": "string"}, "description": "Full-text search in message text", }, ], "responses": json_response, } }, "/api/accounts/{id}/channels/add": { "post": { "summary": "Add channel to account", "requestBody": json_body( { "channel_id": {"type": "string", "example": "-1001234567890"}, "name": {"type": "string", "example": "Research feed"}, }, ["channel_id"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/channels/remove": { "post": { "summary": "Remove channel from account", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**json_response, **error_response}, } }, "/api/accounts/{id}/jobs/scrape": { "post": { "summary": "Scrape channels for account", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}} ), "responses": accepted_response, } }, "/api/accounts/{id}/jobs/export": { "post": { "summary": "Export all channels for account", "requestBody": json_body({}), "responses": accepted_response, } }, "/api/accounts/{id}/jobs/export-channel": { "post": { "summary": "Export one channel for account", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**accepted_response, **error_response}, } }, "/api/accounts/{id}/jobs/rescrape-media": { "post": { "summary": "Rescrape media for account channel", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**accepted_response, **error_response}, } }, "/api/accounts/{id}/jobs/fix-media": { "post": { "summary": "Fix missing media for account channel", "requestBody": json_body( {"channel_id": {"type": "string", "example": "-1001234567890"}}, ["channel_id"], ), "responses": {**accepted_response, **error_response}, } }, "/api/accounts/{id}/jobs/refresh-dialogs": { "post": { "summary": "Refresh dialogs for account", "requestBody": json_body({}), "responses": accepted_response, } }, "/api/accounts/{id}/settings/media": { "post": { "summary": "Toggle media scraping for account", "requestBody": json_body( {"value": {"type": "boolean", "example": True}}, ["value"], ), "responses": {**accepted_response, **error_response}, } }, "/api/accounts/{id}/continuous": { "get": { "summary": "Continuous scraping status for account", "parameters": [ { "name": "id", "in": "path", "required": True, "schema": {"type": "string"}, } ], "responses": json_response, }, "post": { "summary": "Update continuous scraping for account", "requestBody": json_body( { "enabled": {"type": "boolean", "example": True}, "interval_minutes": {"type": "integer", "example": 5}, "run_all_tracked": {"type": "boolean", "example": False}, "channels": { "type": "array", "items": {"type": "string"}, "example": ["-1001234567890"], }, } ), "responses": {**json_response, **error_response}, }, }, }, } # ── Request Handler ────────────────────────────────────────────────────── class TelegramScraperRequestHandler(BaseHTTPRequestHandler): server_version = "TelegramScraperWebUI/0.1" @property def app(self) -> "TelegramScraperWebServer": return self.server # type: ignore[return-value] # ── GET ────────────────────────────────────────────────────────────── def do_GET(self) -> None: parsed = urllib.parse.urlparse(self.path) path = parsed.path query = urllib.parse.parse_qs(parsed.query) if path == "/": return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8") if path == "/settings": return self.serve_file(WEBUI_DIR / "settings.html", "text/html; charset=utf-8") if path == "/viewer": return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8") if path in {"/swagger", "/swigger", "/docs"}: return self.serve_file(WEBUI_DIR / "swagger.html", "text/html; charset=utf-8") if path.startswith("/static/"): relative = path.removeprefix("/static/") return self.serve_static(relative) if path.startswith("/media/"): relative = path.removeprefix("/media/") return self.serve_media(relative) if path == "/openapi.json": return self.send_json(openapi_payload()) if path == "/api/dashboard": return self.send_json( dashboard_payload(self.app.job_runner, self.app.legacy_account_id) ) if path == "/api/auth": if self.app.legacy_account_id: return self.send_json(self.app.auth_manager.auth_state(self.app.legacy_account_id)) return self.send_json(self._legacy_auth_snapshot()) if path == "/api/continuous": if self.app.legacy_account_id: return self.send_json(self.app.continuous_orchestrator.snapshot_for(self.app.legacy_account_id)) return self.send_json({"config": {}, "status": {"running": False, "logs": [], "log_entries": []}}) if path == "/health/continuous": if self.app.legacy_account_id: return self.send_json(self.app.continuous_orchestrator.snapshot_for(self.app.legacy_account_id)) return self.send_json({"config": {}, "status": {"running": False, "logs": [], "log_entries": []}}) if path == "/health": cs_snapshot = self.app.continuous_orchestrator.snapshot() return self.send_json( health_payload( DATA_DIR, SESSION_DIR, STATE_STORE, cs_snapshot, self.app.job_runner.queue.qsize(), list_accounts(DATA_DIR), ) ) 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. 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/"): job_id = path.rsplit("/", 1)[-1] job = self.app.job_runner.get_job(job_id) if not job: return self.send_error_json(HTTPStatus.NOT_FOUND, "Job not found") return self.send_json(job) if path == "/api/channels": return self.send_json(list_channels_snapshot(self.app.legacy_account_id)) if path.startswith("/api/channels/") and path.endswith("/messages"): parts = path.split("/") try: channel_id = normalize_channel_id(urllib.parse.unquote(parts[3])) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) try: limit = max(1, min(int(query.get("limit", ["120"])[0]), 300)) except (TypeError, ValueError): return self.send_error_json(HTTPStatus.BAD_REQUEST, "Invalid 'limit' query parameter") before = query.get("before") try: before_message_id = int(before[0]) if before else None except (TypeError, ValueError): return self.send_error_json(HTTPStatus.BAD_REQUEST, "Invalid 'before' query parameter") search = (query.get("search") or query.get("q") or [""])[0].strip() account_id = self.app.legacy_account_id payload = { "channel_id": channel_id, "messages": load_messages( account_id, channel_id, limit=limit, before_message_id=before_message_id, search=search or None ), "channel": next( ( item for item in list_channels_snapshot(account_id) if item["channel_id"] == channel_id ), None, ), } return self.send_json(payload) # ── /api/accounts/* routes ────────────────────────────────────── if path == "/api/accounts": return self._handle_get_accounts_list() if path.startswith("/api/accounts/"): return self._handle_get_account(path, query) return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found") def _legacy_auth_snapshot(self) -> dict[str, Any]: state = load_state() return { "phase": "unknown", "status": "unknown", "details": "", "qr_url": None, "qr_image": None, "phone": None, "user_id": None, "saved_credentials": { "api_id": state.get("api_id"), "api_hash_present": bool(state.get("api_hash")), }, "auth_status": auth_status(), } def _handle_get_accounts_list(self) -> None: ids = list_accounts(DATA_DIR) accounts = [] for account_id in ids: acc_state = load_account(DATA_DIR, account_id) auth_info = auth_status_for(account_id) cs_info = self.app.continuous_orchestrator.snapshot_for(account_id) accounts.append({ "id": account_id, "label": acc_state.get("label", ""), "auth": auth_info, "status": auth_info.get("status", "unknown"), "continuous_running": cs_info.get("status", {}).get("running", False), "health": account_health_summary(account_id, self.app.job_runner), }) return self.send_json({"accounts": accounts}) def _handle_get_account(self, path: str, query: dict[str, list[str]]) -> None: rest = path[len("/api/accounts/"):] parts = rest.split("/") account_id = urllib.parse.unquote(parts[0]) sub = parts[1:] if not self._account_exists_or_404(account_id): return if not sub: return self._handle_get_account_dashboard(account_id) if sub == ["auth"]: return self.send_json(self.app.auth_manager.auth_state(account_id)) if sub == ["health"]: return self.send_json(account_health_summary(account_id, self.app.job_runner)) if sub == ["export"]: return self.send_json({ "version": 1, "account_id": account_id, "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": try: channel_id = normalize_channel_id(urllib.parse.unquote(sub[1])) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) return self._handle_get_account_channel_messages(account_id, channel_id, query) if sub == ["continuous"]: return self.send_json( self.app.continuous_orchestrator.snapshot_for(account_id) ) return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found") def _handle_get_account_dashboard(self, account_id: str) -> None: acc_state = load_account(DATA_DIR, account_id) channels = list_channels_snapshot(account_id) auth_info = auth_status_for(account_id) cs_info = self.app.continuous_orchestrator.snapshot_for(account_id) payload = { "account_id": account_id, "label": acc_state.get("label", ""), "auth": auth_info, "auth_detail": self.app.auth_manager.auth_state(account_id), "channels": channels, "jobs": self.app.job_runner.recent_jobs(account_id=account_id), "continuous": cs_info, "scrape_media": bool(acc_state.get("scrape_media", True)), "health": account_health_summary(account_id, self.app.job_runner), } return self.send_json(payload) 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") 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: if runner.is_stream_revoked(stream_id): # Capped: this stream is the oldest and must close. break 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()) 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)) def _handle_get_account_channel_messages( self, account_id: str, channel_id: str, query: dict[str, list[str]] ) -> None: try: limit = max(1, min(int(query.get("limit", ["120"])[0]), 300)) except (TypeError, ValueError): return self.send_error_json(HTTPStatus.BAD_REQUEST, "Invalid 'limit' query parameter") before = query.get("before") try: before_message_id = int(before[0]) if before else None except (TypeError, ValueError): return self.send_error_json(HTTPStatus.BAD_REQUEST, "Invalid 'before' query parameter") search = (query.get("search") or query.get("q") or [""])[0].strip() payload = { "channel_id": channel_id, "messages": load_messages( account_id, channel_id, limit=limit, before_message_id=before_message_id , search=search or None ), "channel": next( ( item for item in list_channels_snapshot(account_id) if item["channel_id"] == channel_id ), None, ), } return self.send_json(payload) # ── HEAD ───────────────────────────────────────────────────────────── def do_HEAD(self) -> None: parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/": return self.serve_file(WEBUI_DIR / "index.html", "text/html; charset=utf-8", head_only=True) if path == "/settings": return self.serve_file(WEBUI_DIR / "settings.html", "text/html; charset=utf-8", head_only=True) if path == "/viewer": return self.serve_file(WEBUI_DIR / "viewer.html", "text/html; charset=utf-8", head_only=True) if path in {"/swagger", "/swigger", "/docs"}: return self.serve_file(WEBUI_DIR / "swagger.html", "text/html; charset=utf-8", head_only=True) if path.startswith("/static/"): relative = path.removeprefix("/static/") return self.serve_static(relative, head_only=True) if path.startswith("/media/"): relative = path.removeprefix("/media/") return self.serve_media(relative, head_only=True) if ( path in {"/api/dashboard", "/api/auth", "/api/continuous", "/api/jobs", "/api/channels", "/api/accounts", "/health", "/health/continuous", "/openapi.json"} or path.startswith(("/api/jobs/", "/api/accounts/")) or path.startswith("/api/channels/") and path.endswith("/messages") ): # M-4: for the job-events route, mirror GET's behavior and verify # the job actually exists before responding with 200. if path.startswith("/api/jobs/") and path.endswith("/events"): job_id = path.split("/")[-2] if path.endswith("/events") else None if job_id and 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", "application/json; charset=utf-8") self.end_headers() return return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found") # ── 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") # ── Legacy endpoints ──────────────────────────────────────────── if path == "/api/settings/media": value = parse_bool(body.get("value")) if self.app.legacy_account_id: return self._handle_account_settings_media(self.app.legacy_account_id, value) # No legacy account: persist to the (global) legacy store directly. # The ScraperJobService set_scrape_media job is a passthrough, so # enqueueing it alone would silently drop the setting. def _media_mutate(global_state: dict[str, Any]) -> None: global_state["scrape_media"] = value STATE_STORE.update(_media_mutate) return self.send_json({"ok": True, "scrape_media": value}) if path == "/api/continuous": try: enabled = parse_bool(body.get("enabled")) interval_minutes = int(body.get("interval_minutes", 1)) # F-5: if `channels` key is present but not a list, reject # with 400 instead of silently wiping the stored list. raw_channels = body.get("channels") if raw_channels is not None and not isinstance(raw_channels, list): return self.send_error_json( HTTPStatus.BAD_REQUEST, "`channels` must be a list if provided", ) if raw_channels is not None: channels, dropped = clean_continuous_channels(raw_channels) else: # Key absent → keep existing stored channel list. channels = None dropped = [] run_all_tracked = parse_bool(body.get("run_all_tracked", True), default=True) if self.app.legacy_account_id: if channels is not None: payload = self.app.continuous_orchestrator.update_for( account_id=self.app.legacy_account_id, enabled=enabled, interval_minutes=interval_minutes, channels=channels, run_all_tracked=run_all_tracked, ) else: # channels absent: read existing from disk, do not overwrite. existing = load_account(DATA_DIR, self.app.legacy_account_id) existing_channels, _ = clean_continuous_channels( existing.get("continuous_scraping", {}).get("channels", []) ) payload = self.app.continuous_orchestrator.update_for( account_id=self.app.legacy_account_id, enabled=enabled, interval_minutes=interval_minutes, channels=existing_channels, run_all_tracked=run_all_tracked, ) else: payload = {"config": {}, "status": {"running": False, "logs": [], "log_entries": []}} except Exception as exc: return self.send_bad_request_from_exc( exc, fallback="Failed to update continuous scraping" ) if isinstance(payload, dict): payload["dropped_invalid"] = dropped return self.send_json(payload) if path == "/api/auth/credentials": api_id = body.get("api_id") api_hash = str(body.get("api_hash", "")).strip() if not api_id or not api_hash: return self.send_error_json(HTTPStatus.BAD_REQUEST, "api_id and api_hash are required") try: state = load_state() state["api_id"] = int(api_id) state["api_hash"] = api_hash save_state(state) payload = self._legacy_auth_snapshot() except Exception as exc: return self.send_bad_request_from_exc( exc, fallback="Failed to save API credentials" ) return self.send_json(payload) if path == "/api/auth/qr/start": return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/qr/start instead.") if path == "/api/auth/phone/request": return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/phone/request instead.") if path == "/api/auth/phone/submit": return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/phone/submit instead.") if path == "/api/auth/password": return self.send_error_json(HTTPStatus.BAD_REQUEST, "Legacy auth is not available in multi-account mode. Use /api/accounts/{id}/auth/password instead.") if path == "/api/channels/add": try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) if self.app.legacy_account_id: return self._handle_account_channel_add(self.app.legacy_account_id, channel_id, body.get("name")) # L-6: use the atomic mutator to avoid lost-update races. def _add_mutate(state: dict[str, Any]) -> None: if channel_id not in state.get("channels", {}): state.setdefault("channels", {})[channel_id] = 0 if body.get("name"): state.setdefault("channel_names", {})[channel_id] = str(body["name"]).strip() STATE_STORE.update(_add_mutate) return self.send_json({"ok": True, "channel_id": channel_id}) if path == "/api/channels/remove": try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) if self.app.legacy_account_id: return self._handle_account_channel_remove(self.app.legacy_account_id, channel_id) # L-6: use the atomic mutator to avoid lost-update races. existed = [False] def _remove_mutate(state: dict[str, Any]) -> None: chans = state.get("channels", {}) if channel_id in chans: existed[0] = True del chans[channel_id] state.get("channel_names", {}).pop(channel_id, None) STATE_STORE.update(_remove_mutate) return self.send_json({"ok": existed[0], "channel_id": channel_id}) if path == "/api/jobs/scrape": channel_id = body.get("channel_id") payload = {} account_id = self.app.legacy_account_id if account_id: payload["account_id"] = account_id if channel_id: try: payload["channel_id"] = normalize_channel_id(channel_id) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) job = self.app.job_runner.create_job( "scrape_channel", f"Scrape channel {payload['channel_id']}" + (f" [{account_id}]" if account_id else ""), payload, ) else: job = self.app.job_runner.create_job( "scrape_all", "Scrape all tracked channels" + (f" [{account_id}]" if account_id else ""), payload, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) if path == "/api/jobs/export": payload = {} if self.app.legacy_account_id: payload["account_id"] = self.app.legacy_account_id job = self.app.job_runner.create_job( "export_all", "Export all tracked channels", payload, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) if path == "/api/jobs/export-channel": try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) payload = {"channel_id": channel_id} if self.app.legacy_account_id: payload["account_id"] = self.app.legacy_account_id job = self.app.job_runner.create_job( "export_channel", f"Export channel {channel_id}", payload, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) if path == "/api/jobs/rescrape-media": try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) payload = {"channel_id": channel_id} if self.app.legacy_account_id: payload["account_id"] = self.app.legacy_account_id job = self.app.job_runner.create_job( "rescrape_media", f"Rescrape media for {channel_id}", payload, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) if path == "/api/jobs/fix-media": try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) payload = {"channel_id": channel_id} if self.app.legacy_account_id: payload["account_id"] = self.app.legacy_account_id job = self.app.job_runner.create_job( "fix_missing_media", f"Fix missing media for {channel_id}", payload, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) if path == "/api/jobs/refresh-dialogs": payload = {} if self.app.legacy_account_id: payload["account_id"] = self.app.legacy_account_id job = self.app.job_runner.create_job( "refresh_dialogs", "Refresh Telegram dialogs list", payload, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) # ── /api/accounts/* POST routes ───────────────────────────────── if path == "/api/accounts": return self._handle_post_accounts_create(body) if path == "/api/accounts/import": return self._handle_post_accounts_import(body) if path.startswith("/api/accounts/"): return self._handle_post_account(path, body) return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found") def _handle_post_accounts_create(self, body: dict[str, Any]) -> None: account_id = str(body.get("account_id", "")).strip() if not account_id: return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id is required") if not account_id.replace("-", "").replace("_", "").isalnum(): return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id must be alphanumeric (dashes and underscores allowed)") existing = list_accounts(DATA_DIR) if account_id in existing: return self.send_error_json(HTTPStatus.BAD_REQUEST, f"Account '{account_id}' already exists") label = str(body.get("label", account_id)).strip() api_id = body.get("api_id") api_hash = str(body.get("api_hash", "")).strip() def mutate_global(state: dict[str, Any]) -> None: accounts = state.setdefault("accounts", []) if account_id not in accounts: accounts.append(account_id) get_global_store(DATA_DIR).update(mutate_global) store = get_account_store(DATA_DIR, account_id) init_state = { "label": label, "api_id": int(api_id) if api_id else None, "api_hash": api_hash if api_id else None, "channels": {}, "channel_names": {}, "scrape_media": True, "forwarding_rules": [], "continuous_scraping": { "enabled": False, "interval_minutes": 1, "channels": [], "run_all_tracked": True, }, } store.save(init_state) self.app.continuous_orchestrator.add_account(account_id, auto_start=False) return self.send_json({"ok": True, "account_id": account_id}) def _handle_post_accounts_import(self, body: dict[str, Any]) -> None: account_id = str(body.get("account_id") or body.get("id") or "").strip() state = body.get("state") if not account_id: return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id is required") if not account_id.replace("-", "").replace("_", "").isalnum(): return self.send_error_json(HTTPStatus.BAD_REQUEST, "account_id must be alphanumeric (dashes and underscores allowed)") if account_exists(DATA_DIR, account_id): return self.send_error_json(HTTPStatus.BAD_REQUEST, f"Account '{account_id}' already exists") if not isinstance(state, dict): return self.send_error_json(HTTPStatus.BAD_REQUEST, "state object is required") # F-1: normalise the imported continuous channels and drop invalid # entries (path traversal / control chars) before persisting. cs = state.get("continuous_scraping") cs = cs if isinstance(cs, dict) else {} cs_channels, _dropped = clean_continuous_channels(cs.get("channels", [])) try: interval_minutes = int(cs.get("interval_minutes", 1) or 1) except (TypeError, ValueError): return self.send_error_json(HTTPStatus.BAD_REQUEST, "interval_minutes must be a valid integer") imported_state = { "label": str(body.get("label") or state.get("label") or account_id).strip(), "api_id": state.get("api_id"), "api_hash": state.get("api_hash"), "channels": self._clean_imported_channels(state.get("channels")), "channel_names": self._clean_imported_channel_names(state.get("channel_names")), "scrape_media": parse_bool(state.get("scrape_media", True), default=True), "forwarding_rules": state.get("forwarding_rules") if isinstance(state.get("forwarding_rules"), list) else [], "continuous_scraping": { "enabled": parse_bool(cs.get("enabled", False)), "interval_minutes": interval_minutes, "channels": cs_channels, "run_all_tracked": parse_bool(cs.get("run_all_tracked", True), default=True), }, } def mutate_global(global_state: dict[str, Any]) -> None: accounts = global_state.setdefault("accounts", []) if account_id not in accounts: accounts.append(account_id) get_global_store(DATA_DIR).update(mutate_global) get_account_store(DATA_DIR, account_id).save(imported_state) self.app.continuous_orchestrator.add_account(account_id, auto_start=False) self.app.continuous_orchestrator.refresh_account(account_id) return self.send_json({"ok": True, "account_id": account_id}) def _handle_post_account(self, path: str, body: dict[str, Any]) -> None: rest = path[len("/api/accounts/"):] parts = rest.split("/") account_id = urllib.parse.unquote(parts[0]) sub = parts[1:] if not sub: return self.send_error_json(HTTPStatus.BAD_REQUEST, "Missing endpoint") if not self._account_exists_or_404(account_id): return if sub == ["auth", "credentials"]: return self._handle_post_account_auth_credentials(account_id, body) if sub == ["auth", "qr", "start"]: return self._handle_post_account_auth_qr_start(account_id) if sub == ["auth", "phone", "request"]: return self._handle_post_account_auth_phone_request(account_id, body) if sub == ["auth", "phone", "submit"]: return self._handle_post_account_auth_phone_submit(account_id, body) if sub == ["auth", "password"]: return self._handle_post_account_auth_password(account_id, body) if sub == ["channels", "add"]: return self._handle_account_channel_add(account_id, body.get("channel_id"), body.get("name")) if sub == ["channels", "remove"]: return self._handle_account_channel_remove(account_id, body.get("channel_id")) if sub == ["jobs", "scrape"]: return self._handle_account_job_scrape(account_id, body) if sub == ["jobs", "export"]: return self._handle_account_job_export(account_id) if sub == ["jobs", "export-channel"]: return self._handle_account_job_export_channel(account_id, body) if sub == ["jobs", "rescrape-media"]: return self._handle_account_job_rescrape_media(account_id, body) if sub == ["jobs", "fix-media"]: return self._handle_account_job_fix_media(account_id, body) if sub == ["jobs", "refresh-dialogs"]: return self._handle_account_job_refresh_dialogs(account_id) if sub == ["settings", "media"]: return self._handle_account_settings_media(account_id, parse_bool(body.get("value"))) if sub == ["continuous"]: return self._handle_account_continuous(account_id, body) return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found") def _account_exists_or_404(self, account_id: str) -> bool: if not account_id or not account_exists(DATA_DIR, account_id): self.send_error_json(HTTPStatus.NOT_FOUND, f"Account '{account_id}' not found") return False return True def _handle_post_account_auth_credentials(self, account_id: str, body: dict[str, Any]) -> None: api_id = body.get("api_id") api_hash = str(body.get("api_hash", "")).strip() if not api_id or not api_hash: return self.send_error_json(HTTPStatus.BAD_REQUEST, "api_id and api_hash are required") try: payload = self.app.auth_manager.save_credentials(account_id, int(api_id), api_hash) except Exception as exc: return self.send_bad_request_from_exc(exc, fallback="Failed to save API credentials") return self.send_json(payload) def _handle_post_account_auth_qr_start(self, account_id: str) -> None: try: payload = self.app.auth_manager.start_qr_login(account_id) except Exception as exc: return self.send_bad_request_from_exc(exc, fallback="Failed to start QR login") 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_bad_request_from_exc(exc, fallback="Failed to request phone code") _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_bad_request_from_exc(exc, fallback="Failed to submit phone code") _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_bad_request_from_exc(exc, fallback="Failed to submit password") _record_auth_success(ip, account_id) return self.send_json(payload) def _clean_imported_channels(self, channels: Any) -> dict[str, Any]: if not isinstance(channels, dict): return {} cleaned: dict[str, Any] = {} for raw_channel_id, last_message_id in channels.items(): try: channel_id = normalize_channel_id(raw_channel_id) except ValueError: continue cleaned[channel_id] = last_message_id return cleaned def _clean_imported_channel_names(self, channel_names: Any) -> dict[str, str]: if not isinstance(channel_names, dict): return {} cleaned: dict[str, str] = {} for raw_channel_id, name in channel_names.items(): try: channel_id = normalize_channel_id(raw_channel_id) except ValueError: continue cleaned[channel_id] = str(name).strip() return cleaned def _handle_account_channel_add(self, account_id: str, channel_id: Any, name: str | None) -> None: try: channel_id = normalize_channel_id(channel_id) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) store = get_account_store(DATA_DIR, account_id) def mutate(state: dict[str, Any]) -> None: if channel_id not in state.setdefault("channels", {}): state["channels"][channel_id] = 0 if name: state.setdefault("channel_names", {})[channel_id] = str(name).strip() store.update(mutate) self.app.continuous_orchestrator.refresh_account(account_id) return self.send_json({"ok": True, "channel_id": channel_id}) def _handle_account_channel_remove(self, account_id: str, channel_id: Any) -> None: try: channel_id = normalize_channel_id(channel_id) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) store = get_account_store(DATA_DIR, account_id) existed = [False] def mutate(state: dict[str, Any]) -> None: chans = state.get("channels", {}) if channel_id in chans: existed[0] = True del chans[channel_id] state.get("channel_names", {}).pop(channel_id, None) store.update(mutate) self.app.continuous_orchestrator.refresh_account(account_id) return self.send_json({"ok": existed[0], "channel_id": channel_id}) def _handle_account_job_scrape(self, account_id: str, body: dict[str, Any]) -> None: channel_id = body.get("channel_id") if channel_id: try: channel_id = normalize_channel_id(channel_id) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) job = self.app.job_runner.create_job( "scrape_channel", f"Scrape channel {channel_id} [{account_id}]", {"channel_id": str(channel_id), "account_id": account_id}, ) else: job = self.app.job_runner.create_job( "scrape_all", f"Scrape all tracked channels [{account_id}]", {"account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_job_export(self, account_id: str) -> None: job = self.app.job_runner.create_job( "export_all", f"Export all tracked channels [{account_id}]", {"account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_job_export_channel(self, account_id: str, body: dict[str, Any]) -> None: try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) job = self.app.job_runner.create_job( "export_channel", f"Export channel {channel_id} [{account_id}]", {"channel_id": channel_id, "account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_job_rescrape_media(self, account_id: str, body: dict[str, Any]) -> None: try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) job = self.app.job_runner.create_job( "rescrape_media", f"Rescrape media for {channel_id} [{account_id}]", {"channel_id": channel_id, "account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_job_fix_media(self, account_id: str, body: dict[str, Any]) -> None: try: channel_id = normalize_channel_id(body.get("channel_id")) except ValueError as exc: return self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) job = self.app.job_runner.create_job( "fix_missing_media", f"Fix missing media for {channel_id} [{account_id}]", {"channel_id": channel_id, "account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_job_refresh_dialogs(self, account_id: str) -> None: job = self.app.job_runner.create_job( "refresh_dialogs", f"Refresh Telegram dialogs [{account_id}]", {"account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_settings_media(self, account_id: str, value: bool) -> None: store = get_account_store(DATA_DIR, account_id) def mutate(state: dict[str, Any]) -> None: state["scrape_media"] = value store.update(mutate) job = self.app.job_runner.create_job( "set_scrape_media", f"Update media scraping setting [{account_id}]", {"value": value, "account_id": account_id}, ) return self.send_json(job.to_dict(), status=HTTPStatus.ACCEPTED) def _handle_account_continuous(self, account_id: str, body: dict[str, Any]) -> None: try: enabled = parse_bool(body.get("enabled")) interval_minutes = int(body.get("interval_minutes", 1)) # F-5: if `channels` key is present but not a list, reject # with 400 instead of silently wiping the stored list. raw_channels = body.get("channels") if raw_channels is not None and not isinstance(raw_channels, list): return self.send_error_json( HTTPStatus.BAD_REQUEST, "`channels` must be a list if provided", ) if raw_channels is not None: channels, dropped = clean_continuous_channels(raw_channels) else: # Key absent → keep existing stored channel list. existing = load_account(DATA_DIR, account_id) channels, _ = clean_continuous_channels( existing.get("continuous_scraping", {}).get("channels", []) ) dropped = [] run_all_tracked = parse_bool(body.get("run_all_tracked", True), default=True) payload = self.app.continuous_orchestrator.update_for( account_id=account_id, enabled=enabled, interval_minutes=interval_minutes, channels=channels, run_all_tracked=run_all_tracked, ) except Exception as exc: return self.send_bad_request_from_exc(exc, fallback="Failed to update continuous scraping") 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 if path.startswith("/api/accounts/"): rest = path[len("/api/accounts/"):] parts = rest.split("/") account_id = urllib.parse.unquote(parts[0]) if len(parts) == 1: return self._handle_delete_account(account_id) return self.send_error_json(HTTPStatus.BAD_REQUEST, "Invalid account path") return self.send_error_json(HTTPStatus.NOT_FOUND, "Not found") def _handle_delete_account(self, account_id: str) -> None: ids = list_accounts(DATA_DIR) if account_id not in ids: return self.send_error_json(HTTPStatus.NOT_FOUND, f"Account '{account_id}' not found") self.app.continuous_orchestrator.remove_account(account_id) self.app.auth_manager.delete_account(account_id) def mutate_global(state: dict[str, Any]) -> None: accounts = state.setdefault("accounts", []) if account_id in accounts: accounts.remove(account_id) get_global_store(DATA_DIR).update(mutate_global) acc_dir = account_data_dir(DATA_DIR, account_id) session_file = Path(account_session_path(SESSION_DIR, account_id)) if acc_dir.exists(): import shutil shutil.rmtree(str(acc_dir), ignore_errors=True) for session_sidecar in ( session_file, Path(str(session_file) + "-wal"), Path(str(session_file) + "-shm"), session_file.with_suffix(session_file.suffix + "-journal"), ): if not session_sidecar.exists(): continue try: session_sidecar.unlink() except OSError: pass return self.send_json({"ok": True, "account_id": account_id}) # ── Helpers ───────────────────────────────────────────────────────── 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, 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 # L-8: When an Origin is present, guard against DNS rebinding: the # Host must resolve to a local/private address, otherwise a remote # attacker domain could make both Host == Origin pass. Requests # without an Origin (curl, plain LAN tools) are unaffected. if host and not _is_trusted_host(host): self.send_error_json(HTTPStatus.FORBIDDEN, "Untrusted host") 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: file_path.relative_to(WEBUI_DIR.resolve()) except ValueError: return self.send_error_json(HTTPStatus.FORBIDDEN, "Forbidden") if not file_path.exists() or not file_path.is_file(): return self.send_error_json(HTTPStatus.NOT_FOUND, "File not found") mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" return self.serve_file(file_path, mime_type, head_only=head_only) def serve_media(self, relative_path: str, head_only: bool = False) -> None: clean = Path(posixpath.normpath(urllib.parse.unquote(relative_path))) file_path = (DATA_DIR / clean).resolve() try: file_path.relative_to(DATA_DIR.resolve()) except ValueError: return self.send_error_json(HTTPStatus.FORBIDDEN, "Forbidden") # M-1: Never serve sensitive state / session / db files. Normal # media documents live under DATA_DIR with a media-file extension; # anything else (state.json, *.db, *.session, sqlite sidecars) is # denied regardless of its location under DATA_DIR. file_suffix = file_path.suffix.lower() file_name = file_path.name.lower() if file_name in _SENSITIVE_FILE_NAMES or file_suffix in _SENSITIVE_FILE_SUFFIXES: return self.send_error_json(HTTPStatus.FORBIDDEN, "Forbidden") # Only allow registered media/document extensions. This keeps the # viewer's image/video/audio/document URLs working while blocking # arbitrary file reads (e.g. /media/../state.json is already blocked # by containment, and any other extension is not a media asset). if file_suffix not in _MEDIA_FILE_EXTENSIONS: return self.send_error_json(HTTPStatus.FORBIDDEN, "Forbidden") if not file_path.exists() or not file_path.is_file(): return self.send_error_json(HTTPStatus.NOT_FOUND, "Media file not found") mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" return self.serve_file(file_path, mime_type, head_only=head_only) def serve_file(self, file_path: Path, content_type: str, head_only: bool = False) -> None: stat = file_path.stat() file_size = stat.st_size last_modified = stat.st_mtime range_header = self.headers.get("Range", "").strip() # M-8: for an empty file there is nothing to range-serve; ignore the # Range header entirely and send the full (empty) 200 response so # clients don't see a spurious 416 for `bytes=0-0` on size-0 files. if file_size == 0: range_header = "" if range_header.startswith("bytes="): try: start, end = self._parse_range(range_header, file_size) except ValueError: self.send_error_json(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "Invalid range") return content_length = end - start + 1 self.send_response(HTTPStatus.PARTIAL_CONTENT) self.send_header("Content-Type", content_type) self.send_header("Accept-Ranges", "bytes") self.send_header( "Last-Modified", time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)), ) self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}") self.send_header("Content-Length", str(content_length)) self._send_security_headers() if "text/html" in content_type: self.send_header("Content-Security-Policy", self._CSP) self.end_headers() if not head_only: self._write_file_range(file_path, start, content_length) else: self.send_response(HTTPStatus.OK) self.send_header("Content-Type", content_type) self.send_header("Accept-Ranges", "bytes") self.send_header( "Last-Modified", time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)), ) self.send_header("Content-Length", str(file_size)) self._send_security_headers() if "text/html" in content_type: self.send_header("Content-Security-Policy", self._CSP) self.end_headers() if not head_only: self._write_file_range(file_path, 0, file_size) def _parse_range(self, range_header: str, file_size: int) -> tuple[int, int]: """Parse a single ``bytes=start-end`` range spec. Raises ``ValueError`` (cleanly caught by the caller → 416) for any malformed / multi-range / out-of-bounds input. Never lets an exception other than ValueError escape. """ try: range_val = range_header.removeprefix("bytes=").strip() # Multi-range requests ("bytes=0-1,5-6") are not supported here. if "," in range_val: raise ValueError("multi-range not supported") if "-" not in range_val: raise ValueError("missing dash") parts = range_val.split("-", 1) if parts[0] == "": end = int(parts[1]) start = max(0, file_size - end) else: start = int(parts[0]) end = int(parts[1]) if parts[1] else file_size - 1 if start < 0 or start >= file_size or end >= file_size or start > end: raise ValueError("out of bounds") return start, end except ValueError: raise except Exception as exc: logger.debug("Unexpected range parse error: %s", exc) raise ValueError("invalid range") from exc def _write_file_range(self, file_path: Path, offset: int, length: int) -> None: chunk_size = 65536 with open(file_path, "rb") as f: f.seek(offset) remaining = length while remaining > 0: chunk = f.read(min(chunk_size, remaining)) if not chunk: break self.wfile.write(chunk) remaining -= len(chunk) # ── L-4: Security headers ───────────────────────────────────────────── _CSP = "default-src 'self'; img-src 'self' data:; style-src 'self'" def _send_security_headers(self) -> None: """Emit standard hardening headers on every HTTP response.""" self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.send_header("Referrer-Policy", "no-referrer") def send_json(self, payload: Any, status: HTTPStatus = HTTPStatus.OK) -> None: raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(raw))) self._send_security_headers() self.end_headers() self.wfile.write(raw) def send_error_json(self, status: HTTPStatus, message: str) -> None: self.send_json({"error": message, "status": int(status)}, status=status) def send_bad_request_from_exc( self, exc: Exception, fallback: str = "Bad request" ) -> None: """Send a 400 JSON error without leaking filesystem paths or other server internals. The caught exception is logged server-side and the client receives only *fallback*.""" logger.exception("Request failed; client sees '%s'", fallback) return self.send_error_json(HTTPStatus.BAD_REQUEST, fallback) def log_message(self, format: str, *args: Any) -> None: safe_path = self.path.split("?", 1)[0] logger.info( "%s %s — %s", self.command, safe_path, args[1] if len(args) > 1 else "-", ) # ── Server ─────────────────────────────────────────────────────────────── class TelegramScraperWebServer(ThreadingHTTPServer): def __init__(self, server_address: tuple[str, int]): super().__init__(server_address, TelegramScraperRequestHandler) self.job_runner = JobRunner() self.auth_manager = TelegramAuthManager() self.continuous_orchestrator = ContinuousScrapeOrchestrator() self.legacy_account_id: str | None = None self._detect_legacy_account() if START_CONTINUOUS: self.continuous_orchestrator.start_all() def _detect_legacy_account(self) -> None: ids = list_accounts(DATA_DIR) if not ids: legacy = load_state() if legacy.get("api_id") and legacy.get("api_hash"): import app_state as as_mod as_mod.migrate_legacy_state(DATA_DIR, SESSION_DIR) ids = list_accounts(DATA_DIR) if ids and len(ids) == 1: self.legacy_account_id = ids[0] def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: server = TelegramScraperWebServer((host, port)) shutdown_event = threading.Event() def _shutdown(signum=None, frame=None): if shutdown_event.is_set(): return logger.warning("Shutdown signal received, shutting down gracefully...") shutdown_event.set() server.continuous_orchestrator.stop_all() server.auth_manager.shutdown() server.job_runner.shutdown() signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) logger.info("Telegram Scraper Web UI listening on http://%s:%s", host, port) logger.info("Press Ctrl+C to stop.") try: while not shutdown_event.is_set(): server.handle_request() except KeyboardInterrupt: _shutdown() finally: server.server_close() logger.info("Server stopped.") if __name__ == "__main__": run_server()