fix(server): harden auth, SSE, state, scraping

- Fix SSE streams not terminating on successful jobs (C-2)
- Anchor data/session paths to BASE_DIR instead of CWD (C-3)
- Guard TelegramAuthManager state with RLock (H-1)
- Replace millisecond job ids with uuid4 (H-2)
- Always redact api_id/api_hash on export, drop include_secrets (H-3)
- Enforce JSON content-type + same-origin on mutating requests (H-4)
- Rate-limit auth attempts and phone-code requests (H-5)
- Deep-copy StateStore.load() on all paths (H-6)
- Cap FloodWait retries in forward_message (H-7)
- De-duplicate forwarding handler registration (H-8)
- Validate continuous channels at ingest, join scrape thread on account
  removal, fix refresh_config status under lock, cap SSE streams and
  JSON body size (M-5, M-6, M-17)
- Add regression tests (33 passing) and REVIEW.md
This commit is contained in:
2026-09-07 11:54:13 +02:00
parent 59824940c6
commit a2468a2a2c
8 changed files with 973 additions and 186 deletions
+39 -9
View File
@@ -2,6 +2,7 @@ import sqlite3
import json
import csv
import asyncio
import logging
import time
import sys
import warnings
@@ -27,6 +28,9 @@ from app_state import (
get_account_store,
)
BASE_DIR = Path(__file__).resolve().parent
logger = logging.getLogger(__name__)
warnings.filterwarnings(
"ignore", message="Using async sessions support is an experimental feature"
)
@@ -90,16 +94,18 @@ def _ensure_session_wal(session_path: str) -> None:
class OptimizedTelegramScraper:
def __init__(self, account_id: Optional[str] = None):
def __init__(self, account_id: Optional[str] = None, base_dir: Optional[Path] = None):
self.account_id = account_id
self.SESSION_DIR = Path("session")
base_dir = base_dir or BASE_DIR
self.BASE_DIR = base_dir
self.SESSION_DIR = base_dir / "session"
self.SESSION_DIR.mkdir(exist_ok=True)
if account_id:
self.DATA_DIR = Path("data") / "accounts" / account_id
self.state_store = get_account_store(Path("data"), account_id)
self.DATA_DIR = base_dir / "data" / "accounts" / account_id
self.state_store = get_account_store(base_dir / "data", account_id)
else:
self.DATA_DIR = Path("data")
self.DATA_DIR = base_dir / "data"
self.state_store = StateStore(self.DATA_DIR / "state.json")
self.DATA_DIR.mkdir(parents=True, exist_ok=True)
@@ -210,6 +216,22 @@ class OptimizedTelegramScraper:
columns = {row[1] for row in cursor.fetchall()}
migrations = []
if "sender_id" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN sender_id INTEGER")
if "first_name" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN first_name TEXT")
if "last_name" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN last_name TEXT")
if "username" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN username TEXT")
if "message" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN message TEXT")
if "media_type" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN media_type TEXT")
if "media_path" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN media_path TEXT")
if "reply_to" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN reply_to INTEGER")
if "post_author" not in columns:
migrations.append("ALTER TABLE messages ADD COLUMN post_author TEXT")
if "views" not in columns:
@@ -222,8 +244,8 @@ class OptimizedTelegramScraper:
for migration in migrations:
try:
conn.execute(migration)
except Exception:
pass
except Exception as e:
logger.warning("Migration failed for %s: %s", migration, e)
if migrations:
conn.commit()
@@ -353,7 +375,7 @@ class OptimizedTelegramScraper:
return False
async def forward_message(
self, message, rule: ForwardingRule, source_channel_id: int = None
self, message, rule: ForwardingRule, source_channel_id: int = None, _retry: int = 0
):
try:
dest_entity = await self._resolve_entity(rule.destination_channel)
@@ -393,9 +415,12 @@ class OptimizedTelegramScraper:
return True
except FloodWaitError as e:
if _retry >= 3:
print(f" Failed to forward message {message.id}: FloodWait retry limit exceeded")
return False
print(f" Rate limited, waiting {e.seconds}s...")
await asyncio.sleep(e.seconds)
return await self.forward_message(message, rule, source_channel_id)
return await self.forward_message(message, rule, source_channel_id, _retry=_retry + 1)
except Exception as e:
print(f" Failed to forward message {message.id}: {e}")
return False
@@ -429,6 +454,11 @@ class OptimizedTelegramScraper:
print("No valid source channels")
return False
# Unregister a previously installed handler so it is never registered twice.
if self.forwarding_handler is not None:
self.client.remove_event_handler(self.forwarding_handler)
self.forwarding_handler = None
@self.client.on(
events.NewMessage(chats=source_channels, incoming=True, outgoing=True)
)