diff --git a/backend/Dockerfile b/backend/Dockerfile index b49a7e4..59808d4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,7 +6,7 @@ && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /build -COPY frontend/package.json frontend/.npmrc ./ +COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./ RUN npm ci COPY frontend/ ./ RUN npm run build diff --git a/backend/app/auth.py b/backend/app/auth.py index 7b64141..e3018ac 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -186,12 +186,13 @@ async def purge_expired() -> None: - """Севшие сессии, state и pending-логины — подчистить (фоновый GC-цикл).""" + """Севшие сессии и oauth_states — подчистить (login_sessions чистит sweeper).""" db = get_db() now = _now().isoformat() await db.execute("DELETE FROM sessions WHERE expires_at < ?", (now,)) await db.execute("DELETE FROM oauth_states WHERE expires_at < ?", (now,)) - await db.execute("DELETE FROM login_sessions WHERE expires_at < ?", (now,)) + # login_sessions чистит login_sweeper_loop (main.py): критерий created_at + # + disconnect RAM-клиента; здесь удалять нельзя — останется зомби-коннект. await db.commit() diff --git a/backend/app/config.py b/backend/app/config.py index b8b84c2..925a69e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,10 @@ upload_dir: str = "" write_limit_n: int = 20 # мутаций на аккаунт за окно write_limit_window: float = 60.0 + # Автоотмена зависшего логина: pending-сессия старше N минут от начала + # входа (аккаунт не создан) отменяется фоновым sweeper'ом — как ручная + # DELETE (строка в БД + disconnect RAM-клиента). + login_stale_minutes: int = 15 # gnexus-auth (SSO): задан client_id — браузерная авторизация через SSO auth_base_url: str = "" diff --git a/backend/app/main.py b/backend/app/main.py index adec51c..9fdf8a0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -47,6 +47,19 @@ await asyncio.sleep(60) +async def login_sweeper_loop() -> None: + """Автоотмена зависших логинов (30 с): pending старше N минут от начала + входа — строка в БД + disconnect RAM-клиента (login_flow.purge_stale_logins).""" + from app.tg.login_flow import purge_stale_logins + + while True: + try: + await purge_stale_logins() + except Exception as exc: # noqa: BLE001 — не роняем цикл + print(f"login sweeper error: {exc}", flush=True) + await asyncio.sleep(30) + + @asynccontextmanager async def lifespan(_application: FastAPI): await init_db() @@ -58,6 +71,7 @@ tasks = [ asyncio.create_task(manager.persist_loop()), asyncio.create_task(gc_loop()), + asyncio.create_task(login_sweeper_loop()), asyncio.create_task(manager.warmup()), ] try: diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index e2496de..3c4a266 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -12,6 +12,7 @@ from fastapi import Request from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings from starlette.responses import Response from app.mcp.context import set_mcp_user, reset_mcp_user @@ -108,6 +109,7 @@ stateless_http=True, json_response=True, streamable_http_path="/", + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), ) register_tools(mcp) diff --git a/backend/app/tg/login_flow.py b/backend/app/tg/login_flow.py index 836bb5c..547fb0d 100644 --- a/backend/app/tg/login_flow.py +++ b/backend/app/tg/login_flow.py @@ -100,6 +100,7 @@ try: await client.connect() sent = await client.send_code_request(phone) + print(f"[login] send_code_request ok: sent={sent}", flush=True) except errors.PhoneNumberInvalidError as exc: await client.disconnect() raise DomainError(422, "phone number is invalid") from exc @@ -314,6 +315,10 @@ flood_sleep_threshold=60, request_retries=3, ) + @property + def session(self): + return self._client.session + async def connect(self): # noqa: ANN201 await self._client.connect() return self._client @@ -331,4 +336,29 @@ from contextlib import suppress with suppress(Exception): - await self._client.disconnect() \ No newline at end of file + await self._client.disconnect() + +async def purge_stale_logins() -> int: + """Автоотмена зависших логинов (фоновый sweeper, main.login_sweeper_loop). + + Pending-сессия старше login_stale_minutes от created_at = «аккаунт не + создан за N минут» (финализированный логин строки не оставляет) — + отменяется как ручная DELETE: строка в БД + disconnect RAM-клиента + (иначе остаётся зомби-коннект к Telegram). Единственный владелец + очистки login_sessions: auth.purge_expired их больше не удаляет. + """ + stale_min = get_settings().login_stale_minutes + cutoff = (_now() - timedelta(minutes=stale_min)).isoformat() + db = get_db() + cursor = await db.execute( + "SELECT id, step FROM login_sessions WHERE created_at < ?", (cutoff,) + ) + rows = await cursor.fetchall() + for row in rows: + print( + f"[login] auto-cancel stale login {row['id']} " + f"(age > {stale_min}m, step={row['step']})", + flush=True, + ) + await _cleanup(db, row["id"]) + return len(rows) diff --git a/backend/app/tg/manager.py b/backend/app/tg/manager.py index 299ff52..dff79cb 100644 --- a/backend/app/tg/manager.py +++ b/backend/app/tg/manager.py @@ -16,7 +16,7 @@ import asyncio from contextlib import suppress -from telethon import errors +from telethon import TelegramClient, errors from telethon.sessions import StringSession from app.config import get_settings