fix(server): harden deployment, media, state, jobs
- Lock down /media/: deny state.json, DBs, sessions; allowlist extensions incl. archives/docs (M-1) - parse_bool() fixes; HEAD 404; shutdown drains queue; range edge cases (M-3, M-4, M-7, M-8) - int() coercion -> 400; no filesystem paths in errors; path-only access log (M-19, L-1) - Security headers, QR TTL 60s, trusted-host allowlist, legacy add/remove via update() (L-4, L-5, L-6, L-8) - Clean continuous channels on import and migration; restart-during-drain; tombstone managers (F-1, F-3, F-4) - Durability: fsync + unique tmp + stale sweep + 0600/0700 perms (M-10, M-18) - Jobs run on dedicated loop thread; set_scrape_media passthrough; media chunked; state throttled; exact media file reuse; honest scrape failure status (M-11, M-12, M-13, M-14) - Health aggregates per-account; legacy GETs delegate post-migration (M-15, M-9) - k8s: runAsNonRoot 1000 + resource limits, no readOnlyRootFilesystem (M-16) - UI: dropped-invalid and credentials-reentry toasts; swagger XSS-safe (F-2, L-9, L-2) - CI: non-blocking pip-audit job in both workflows (L-3) - 50 tests passing; REVIEW.md updated (C-1/M-20 won't fix: local-only by design)
This commit is contained in:
+67
-8
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -10,22 +11,55 @@ logger = logging.getLogger(__name__)
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _run_in_new_loop(coro_factory):
|
||||
"""Run an awaitable on a dedicated thread with its own event loop.
|
||||
|
||||
Returns the coroutine's result. This avoids ``asyncio.run()`` raising
|
||||
``RuntimeError`` when the caller runs on a thread that already has a
|
||||
running event loop (e.g. job threads, auth-loop threads).
|
||||
"""
|
||||
result = {}
|
||||
error = {}
|
||||
|
||||
def runner():
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
result["value"] = loop.run_until_complete(coro_factory())
|
||||
except BaseException as exc: # noqa: BLE001 - relay any failure
|
||||
error["value"] = exc
|
||||
finally:
|
||||
try:
|
||||
loop.close()
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
thread = threading.Thread(target=runner, daemon=True)
|
||||
thread.start()
|
||||
thread.join()
|
||||
if "value" in error:
|
||||
raise error["value"]
|
||||
return result.get("value")
|
||||
|
||||
|
||||
class ScraperJobService:
|
||||
def __init__(self, state_store: StateStore):
|
||||
self.state_store = state_store
|
||||
|
||||
def run(self, job_type: str, payload: Dict[str, Any]) -> None:
|
||||
if job_type == "set_scrape_media":
|
||||
# The webui handler already persists the scrape_media setting to
|
||||
# the per-account (or legacy) store before enqueueing this job.
|
||||
# Multi-account mode has no single global change to make, so this
|
||||
# is a passthrough that just records success to keep the
|
||||
# job-status / SSE flow intact.
|
||||
value = bool(payload["value"])
|
||||
|
||||
def mutate(state: Dict[str, Any]) -> None:
|
||||
state["scrape_media"] = value
|
||||
|
||||
self.state_store.update(mutate)
|
||||
logger.info("Media scraping set to %s", value)
|
||||
logger.info("Media scraping set to %s (already persisted by handler)", value)
|
||||
return
|
||||
|
||||
asyncio.run(self._run_async(job_type, payload))
|
||||
# Run the async job on a fresh thread/event loop so we never hit
|
||||
# "asyncio.run() cannot be called from a running event loop".
|
||||
_run_in_new_loop(lambda: self._run_async(job_type, payload))
|
||||
|
||||
async def _run_async(self, job_type: str, payload: Dict[str, Any]) -> None:
|
||||
# Extract account_id from payload, default to None (legacy)
|
||||
@@ -76,9 +110,34 @@ class ScraperJobService:
|
||||
await scraper.client.disconnect()
|
||||
|
||||
async def _scrape_channels(self, scraper, channels: List[str]) -> None:
|
||||
"""Scrape all channels resiliently: a single channel failure does not
|
||||
abort the rest. Each channel's offset is persisted even on failure
|
||||
(see scrape_channel's finally block), so partial progress is retained.
|
||||
If *every* channel fails, raise so the job is marked failed.
|
||||
"""
|
||||
failed: List[str] = []
|
||||
for channel_id in channels:
|
||||
offset = int(scraper.state.get("channels", {}).get(channel_id, 0) or 0)
|
||||
await scraper.scrape_channel(channel_id, offset)
|
||||
try:
|
||||
ok = await scraper.scrape_channel(channel_id, offset)
|
||||
except Exception: # noqa: BLE001 - scrape_channel re-raises some errors
|
||||
logger.exception("Scrape of channel %s raised", channel_id)
|
||||
failed.append(channel_id)
|
||||
continue
|
||||
if not ok:
|
||||
logger.warning("Scrape of channel %s reported failure", channel_id)
|
||||
failed.append(channel_id)
|
||||
if failed and len(failed) == len(channels):
|
||||
raise RuntimeError(
|
||||
"All scrape target(s) failed: " + ", ".join(failed)
|
||||
)
|
||||
if failed:
|
||||
logger.warning(
|
||||
"Partial scrape failure — %d/%d channel(s) failed: %s",
|
||||
len(failed),
|
||||
len(channels),
|
||||
", ".join(failed),
|
||||
)
|
||||
|
||||
def _import_scraper_class(self):
|
||||
from telegram_scraper_with_forwarding import OptimizedTelegramScraper
|
||||
|
||||
Reference in New Issue
Block a user