Вход через Яндекс (OAuth) + сессии + бэкенд пейволла (выключен флагом)

- OAuth Yandex ID: /api/auth/yandex/{start,callback}, cookie-сессии, /api/me, logout
- Меню входа Telegram/Яндекс в шапке, определение сессии
- Бэкенд пейволла (WEB_ACCOUNTS/подписка, purpose file/sub) — под флагом
  PAYWALL_ENABLED (по умолчанию 0: прежний порог авторизации, ничего не ломается)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 08:01:01 +00:00
parent 98a51d4b18
commit 414a8fd0f4
2 changed files with 347 additions and 30 deletions

View File

@@ -23,7 +23,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from starlette.background import BackgroundTask
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
# ── Config ────────────────────────────────────────────────────────────────────
@@ -48,6 +48,20 @@ PAY_MAX_RUB = 100000.0
# Тариф API Pro (правится здесь). Активируется при purpose=api_pro.
API_PRO_PRICE_RUB = float(os.getenv("API_PRO_PRICE_RUB", "299"))
API_PRO_DAYS = 30
# Пейволл больших файлов: ≤50МБ и ≤100стр — бесплатно; больше — оплата.
FILE_PRICE_RUB = float(os.getenv("FILE_PRICE_RUB", "99")) # разовая за файл
SUB_PRICE_RUB = float(os.getenv("SUB_PRICE_RUB", "299")) # подписка/мес
SUB_DAYS = 30
# Пейволл ВЫКЛЮЧЕН по умолчанию: пока нет фронта пейволла — работает старый порог
# авторизации (иначе большие файлы у залогиненных зависнут). Включим флагом = 1.
PAYWALL_ENABLED = os.getenv("PAYWALL_ENABLED", "0").strip() in {"1", "true", "yes", "on"}
# ── OAuth: Yandex ID ──────────────────────────────────────────────────────────
YANDEX_CLIENT_ID = os.getenv("YANDEX_CLIENT_ID", "")
YANDEX_CLIENT_SECRET = os.getenv("YANDEX_CLIENT_SECRET", "")
YANDEX_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/yandex/callback"
SESSION_COOKIE = "p2s_session"
SESSION_DAYS = 30
# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
@@ -242,7 +256,27 @@ def _db_ensure_auth_table():
last_req REAL
)
""")
# Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro.
# Аккаунты с подпиской. account_id: 'tg:<id>' (позже yandex:/vk:/email:).
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_ACCOUNTS (
account_id TEXT PRIMARY KEY,
sub_until REAL DEFAULT 0,
created_at REAL
)
""")
# Сессии (cookie) для OAuth-входа (Yandex/VK). token → аккаунт.
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_SESSIONS (
token TEXT PRIMARY KEY,
account_id TEXT,
provider TEXT,
name TEXT,
email TEXT,
created_at REAL,
expires_at REAL
)
""")
# Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub.
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
payment_id TEXT PRIMARY KEY,
@@ -811,24 +845,32 @@ def _finalize_submit(job_id, file_path, original_name, stitching, title,
numeration, flip_side, color, pdfa, quality,
size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None):
"""Общий хвост постановки задания: проверка авторизации, запись в БД, _meta.
trusted=True (бизнес-API по ключу) — пропускает порог авторизации по размеру.
trusted=True (бизнес-API по ключу) — пропускает пейволл.
web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id)."""
# Пейволл: ≤50МБ и ≤100стр — бесплатно; больше — нужна активная подписка,
# иначе задание держим (url_res_f='awaiting_payment') до разовой оплаты 99₽.
held = False
if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES):
verified = False
if user_id:
with _verified_users_lock:
ts = _verified_users.get(user_id)
if ts and time.time() - ts <= 86400:
verified = True
# Фоллбэк на БД — авторизация переживает рестарт контейнера
if not verified and _db_is_verified(user_id):
verified = True
if not verified:
_safe_remove(file_path)
raise HTTPException(
status_code=403,
detail="Для файлов больше 100 страниц или 50 МБ необходима авторизация через Telegram.",
)
if _db_sub_active(_account_id(user_id)):
pass # активная подписка — бесплатно
elif PAYWALL_ENABLED:
held = True # держим до оплаты 99₽ или подписки
else:
# Пейволл ещё не включён → прежний порог: нужна TG-авторизация
verified = False
if user_id:
with _verified_users_lock:
ts = _verified_users.get(user_id)
if ts and time.time() - ts <= 86400:
verified = True
if not verified and _db_is_verified(user_id):
verified = True
if not verified:
_safe_remove(file_path)
raise HTTPException(
status_code=403,
detail="Для файлов больше 100 страниц или 50 МБ необходима авторизация через Telegram.",
)
bot_color = _COLOR_MAP.get(color, "цветной")
owner = web_owner_id if web_owner_id is not None else user_id
@@ -857,6 +899,11 @@ def _finalize_submit(job_id, file_path, original_name, stitching, title,
"stitching": stitching,
"created_at": time.time(),
}
if held:
# Задание не идёт в очередь бота, пока не оплатят (файл 99₽ или подписка)
_db_set_url_res_f(job_id, "awaiting_payment")
return {"job_id": job_id, "requires_payment": True,
"file_price": FILE_PRICE_RUB, "sub_price": SUB_PRICE_RUB}
return {"job_id": job_id}
@@ -1031,6 +1078,11 @@ async def status(job_id: str):
if url_res_f in ("processing", "uploading"):
return {"status": "processing", "progress": 50, "queue_pos": 0, "error": None}
if url_res_f == "awaiting_payment":
# Большой файл ждёт оплаты (99₽ за файл или подписка)
return {"status": "awaiting_payment", "progress": 0, "queue_pos": 0, "error": None,
"file_price": FILE_PRICE_RUB, "sub_price": SUB_PRICE_RUB}
if url_res_f in ("ошибка", "error"):
return {"status": "error", "progress": 0, "queue_pos": 0, "error": "Ошибка обработки"}
@@ -1536,7 +1588,46 @@ def _db_record_payment(payment_id, amount, currency, status, purpose, api_key, e
print(f"record payment error: {e}")
def _db_mark_payment_paid(payment_id, purpose, api_key):
def _account_id(user_id) -> str | None:
"""Внутренний id аккаунта. Пока источник — Telegram; позже yandex:/vk:/email:."""
try:
uid = int(user_id or 0)
except (TypeError, ValueError):
uid = 0
return f"tg:{uid}" if uid else None
def _db_sub_active(account_id: str | None) -> bool:
if not account_id:
return False
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
cur = con.execute("SELECT sub_until FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,))
row = cur.fetchone()
return bool(row) and (row[0] or 0) > time.time()
except Exception:
return False
def _db_activate_sub(account_id: str, days: int):
if not account_id:
return
with sqlite3.connect(DB_PATH, timeout=10) as con:
cur = con.execute("SELECT sub_until FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,))
row = cur.fetchone()
base = max(time.time(), (row[0] or 0)) if row else time.time()
until = base + days * 86400
con.execute(
"INSERT INTO WEB_ACCOUNTS (account_id, sub_until, created_at) VALUES (?,?,?) "
"ON CONFLICT(account_id) DO UPDATE SET sub_until=excluded.sub_until",
(account_id, until, time.time()),
)
con.commit()
def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None):
"""extra: metadata платежа (job_id для file, account_id для sub)."""
extra = extra or {}
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute("UPDATE WEB_PAYMENTS SET status='succeeded', paid_at=? WHERE payment_id=?",
@@ -1549,6 +1640,16 @@ def _db_mark_payment_paid(payment_id, purpose, api_key):
con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?",
(until, api_key))
con.commit()
# Разовая оплата файла → снимаем задание с холда (в очередь бота)
if purpose == "file":
job_id = extra.get("job_id")
if job_id and _db_get_url_res_f(job_id) == "awaiting_payment":
_db_set_url_res_f(job_id, "-")
# Подписка → продлеваем аккаунт + освобождаем его задания на холде
if purpose == "sub":
acc = extra.get("account_id")
if acc:
_db_activate_sub(acc, SUB_DAYS)
except Exception as e:
print(f"mark paid error: {e}")
@@ -1563,12 +1664,24 @@ async def pay_create(payload: dict):
purpose = str(payload.get("purpose") or "donate")[:32]
email = (payload.get("email") or "").strip()[:128]
api_key = (payload.get("api_key") or "").strip()[:80] or None
job_id = (payload.get("job_id") or "").strip()[:128] or None
account_id = _account_id(payload.get("user_id"))
if purpose == "api_pro":
amount = API_PRO_PRICE_RUB
description = "pdf2scan API — тариф Business (30 дней)"
if api_key and not _db_get_api_key(api_key):
raise HTTPException(status_code=400, detail="Неизвестный API-ключ")
elif purpose == "file":
amount = FILE_PRICE_RUB
description = "Обработка файла pdf2scan"
if not job_id or _db_get_url_res_f(job_id) != "awaiting_payment":
raise HTTPException(status_code=400, detail="Задание для оплаты не найдено")
elif purpose == "sub":
amount = SUB_PRICE_RUB
description = "Подписка pdf2scan (30 дней)"
if not account_id:
raise HTTPException(status_code=401, detail="Для подписки нужен вход в аккаунт")
else:
try:
amount = round(float(payload.get("amount") or 0), 2)
@@ -1584,7 +1697,8 @@ async def pay_create(payload: dict):
"capture": True,
"confirmation": {"type": "redirect", "return_url": f"{SITE_BASE_URL}/?paid={purpose}"},
"description": description,
"metadata": {"purpose": purpose, "api_key": api_key or "", "email": email},
"metadata": {"purpose": purpose, "api_key": api_key or "", "email": email,
"job_id": job_id or "", "account_id": account_id or ""},
}
if email: # чек 54-ФЗ (нужна подключённая онлайн-касса в ЮKassa)
body["receipt"] = {
@@ -1620,7 +1734,7 @@ async def yookassa_webhook(payload: dict):
amount = float((real.get("amount") or {}).get("value") or 0)
_db_record_payment(pid, amount, "RUB", status, purpose, api_key, meta.get("email") or "")
if status == "succeeded":
_db_mark_payment_paid(pid, purpose, api_key)
_db_mark_payment_paid(pid, purpose, api_key, extra=meta)
return {"ok": True}
@@ -1651,12 +1765,143 @@ async def pay_status(payment_id: str):
rk = meta.get("api_key") or api_key
_db_record_payment(payment_id, amount, "RUB", status, rp, rk, meta.get("email") or "")
if status == "succeeded":
_db_mark_payment_paid(payment_id, rp, rk)
_db_mark_payment_paid(payment_id, rp, rk, extra=meta)
return {"status": status or db_status, "purpose": purpose}
except Exception:
return {"status": db_status, "purpose": purpose}
# ══════════════════════════════════════════════════════════════════════════════
# Аккаунты, сессии, OAuth-вход (Yandex ID)
# ══════════════════════════════════════════════════════════════════════════════
import urllib.parse as _urlparse
def _db_ensure_account(account_id: str):
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute("INSERT OR IGNORE INTO WEB_ACCOUNTS (account_id, sub_until, created_at) VALUES (?,0,?)",
(account_id, time.time()))
con.commit()
except Exception:
pass
def _db_create_session(account_id, provider, name, email) -> str:
token = secrets.token_urlsafe(32)
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute(
"INSERT INTO WEB_SESSIONS (token, account_id, provider, name, email, created_at, expires_at) "
"VALUES (?,?,?,?,?,?,?)",
(token, account_id, provider, (name or "")[:128], (email or "")[:128],
time.time(), time.time() + SESSION_DAYS * 86400),
)
con.commit()
except Exception as e:
print(f"create session error: {e}")
return token
def _db_get_session(token: str):
if not token:
return None
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
cur = con.execute(
"SELECT account_id, provider, name, email, expires_at FROM WEB_SESSIONS WHERE token=?",
(token,))
row = cur.fetchone()
if not row or (row[4] or 0) < time.time():
return None
return {"account_id": row[0], "provider": row[1], "name": row[2], "email": row[3]}
except Exception:
return None
def _db_delete_session(token: str):
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute("DELETE FROM WEB_SESSIONS WHERE token=?", (token,))
con.commit()
except Exception:
pass
def _yandex_token_exchange(code: str) -> str:
data = _urlparse.urlencode({
"grant_type": "authorization_code", "code": code,
"client_id": YANDEX_CLIENT_ID, "client_secret": YANDEX_CLIENT_SECRET,
}).encode()
req = _urlreq.Request("https://oauth.yandex.ru/token", data=data, method="POST")
with _urlreq.urlopen(req, timeout=20) as resp:
return _pjson.loads(resp.read().decode()).get("access_token", "")
def _yandex_userinfo(access_token: str) -> dict:
req = _urlreq.Request("https://login.yandex.ru/info?format=json")
req.add_header("Authorization", f"OAuth {access_token}")
with _urlreq.urlopen(req, timeout=20) as resp:
return _pjson.loads(resp.read().decode())
@app.get("/api/auth/yandex/start")
async def yandex_start():
if not YANDEX_CLIENT_ID:
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен.")
state = secrets.token_hex(16)
url = ("https://oauth.yandex.ru/authorize?response_type=code"
f"&client_id={YANDEX_CLIENT_ID}&redirect_uri={_urlparse.quote(YANDEX_REDIRECT, safe='')}"
f"&state={state}")
resp = RedirectResponse(url)
resp.set_cookie("p2s_oauth_state", state, max_age=600, httponly=True, secure=True, samesite="lax", path="/")
return resp
@app.get("/api/auth/yandex/callback")
async def yandex_callback(request: Request, code: str = "", state: str = ""):
if not code:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
if state and request.cookies.get("p2s_oauth_state") != state:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
try:
access = await asyncio.to_thread(_yandex_token_exchange, code)
info = await asyncio.to_thread(_yandex_userinfo, access)
except Exception as e:
print(f"yandex oauth error: {e}")
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
yid = str(info.get("id") or "")
if not yid:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
email = info.get("default_email") or ""
name = info.get("real_name") or info.get("first_name") or info.get("login") or "Yandex"
account_id = f"yandex:{yid}"
_db_ensure_account(account_id)
token = _db_create_session(account_id, "yandex", name, email)
resp = RedirectResponse(f"{SITE_BASE_URL}/?login=yandex")
resp.set_cookie(SESSION_COOKIE, token, max_age=SESSION_DAYS * 86400,
httponly=True, secure=True, samesite="lax", path="/")
resp.delete_cookie("p2s_oauth_state", path="/")
return resp
@app.get("/api/me")
async def me(request: Request):
sess = _db_get_session(request.cookies.get(SESSION_COOKIE))
if not sess:
return {"logged_in": False}
return {"logged_in": True, "provider": sess["provider"], "name": sess["name"],
"email": sess["email"], "sub_active": _db_sub_active(sess["account_id"])}
@app.post("/api/auth/logout")
async def auth_logout(request: Request):
_db_delete_session(request.cookies.get(SESSION_COOKIE))
resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
_API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>

View File

@@ -847,15 +847,25 @@
<div class="logo-subtitle" data-i18n="logo_subtitle">Создание растровых pdf с эффектом сканирования из электронных pdf документов</div>
</div>
<div id="header-auth">
<button id="tg-login-header-btn" onclick="startTgAuth()" style="display:none;">
<div id="header-auth" style="position:relative;">
<button id="tg-login-header-btn" onclick="toggleLoginMenu(event)" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
<span data-i18n="login_btn">Войти</span>
</button>
<div id="login-menu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid var(--border,#d0d7e2);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.12);padding:6px;z-index:50;min-width:190px;">
<button onclick="startTgAuth();closeLoginMenu();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#2AABEE"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
<span data-i18n="login_via_tg">Через Telegram</span>
</button>
<button onclick="location.href='/api/auth/yandex/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<span style="display:inline-flex;width:18px;height:18px;background:#FC3F1D;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">Я</span>
<span data-i18n="login_via_yandex">Через Яндекс</span>
</button>
</div>
<div id="tg-user-info" style="display:none;">
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
<span id="tg-user-name"></span>
<button id="tg-logout-btn" onclick="logoutTg()" data-i18n="logout_btn">Выйти</button>
<button id="tg-logout-btn" onclick="doLogout()" data-i18n="logout_btn">Выйти</button>
</div>
</div>
</header>
@@ -1132,6 +1142,9 @@
page_title: 'pdf2scan — онлайн сканирование PDF',
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
login_btn: 'Войти',
login_via_tg: 'Через Telegram',
login_via_yandex: 'Через Яндекс',
err_login: 'Не удалось войти. Попробуйте снова.',
logout_btn: 'Выйти',
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
card_file: 'Файл',
@@ -1235,6 +1248,9 @@
page_title: 'pdf2scan — online PDF scan',
logo_subtitle: 'Convert PDF to scanned document online',
login_btn: 'Login',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Login failed. Please try again.',
logout_btn: 'Logout',
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
card_file: 'File',
@@ -1339,6 +1355,9 @@
page_title: 'pdf2scan — Online-PDF-Scan',
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
login_btn: 'Anmelden',
login_via_tg: 'Über Telegram',
login_via_yandex: 'Über Yandex',
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
logout_btn: 'Abmelden',
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
card_file: 'Datei',
@@ -1443,6 +1462,9 @@
page_title: 'pdf2scan — scan PDF en ligne',
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
login_btn: 'Connexion',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Échec de connexion. Réessayez.',
logout_btn: 'Déconnexion',
page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.',
card_file: 'Fichier',
@@ -1547,6 +1569,9 @@
page_title: 'pdf2scan — scansione PDF online',
logo_subtitle: 'Converti PDF in documenti scansionati online',
login_btn: 'Accedi',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Accesso non riuscito. Riprova.',
logout_btn: 'Esci',
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
card_file: 'File',
@@ -1651,6 +1676,9 @@
page_title: 'pdf2scan — escaneo PDF en línea',
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
login_btn: 'Iniciar sesión',
login_via_tg: 'Con Telegram',
login_via_yandex: 'Con Yandex',
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
logout_btn: 'Cerrar sesión',
page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
card_file: 'Archivo',
@@ -1755,6 +1783,9 @@
page_title: 'pdf2scan — digitalização PDF online',
logo_subtitle: 'Converta PDF em documentos digitalizados online',
login_btn: 'Entrar',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Falha no login. Tente novamente.',
logout_btn: 'Sair',
page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.',
card_file: 'Arquivo',
@@ -2103,22 +2134,53 @@
} catch (e) { /* тихо */ }
}
let sessionUser = null; // из /api/me (вход через Яндекс/VK)
function renderHeaderAuth() {
const loginBtn = document.getElementById('tg-login-header-btn');
const userInfo = document.getElementById('tg-user-info');
if (tgUser) {
const name = tgUser
? (tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : ''))
: (sessionUser ? sessionUser.name : null);
if (name) {
loginBtn.style.display = 'none';
userInfo.style.display = 'flex';
document.getElementById('tg-user-name').textContent =
tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : '');
const photo = document.getElementById('tg-user-photo');
photo.style.display = 'none';
document.getElementById('tg-user-name').textContent = name;
document.getElementById('tg-user-photo').style.display = 'none';
} else {
loginBtn.style.display = 'flex';
userInfo.style.display = 'none';
}
}
// ── Меню входа (Telegram / Яндекс) ────────────────────────────────────────
function toggleLoginMenu(e) {
e.stopPropagation();
const m = document.getElementById('login-menu');
m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none';
}
function closeLoginMenu() {
const m = document.getElementById('login-menu');
if (m) m.style.display = 'none';
}
document.addEventListener('click', closeLoginMenu);
async function doLogout() {
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {}
sessionUser = null;
logoutTg(); // чистит localStorage TG + перерисовывает шапку
}
async function loadMe() {
try {
const r = await fetch('/api/me');
const d = await r.json();
sessionUser = d.logged_in ? d : null;
} catch (e) { sessionUser = null; }
renderHeaderAuth();
if (sessionUser) loadHistory();
}
// ── Авторизация через бота ────────────────────────────────────────────────
let _authPollInterval = null;
let _authToken = null;
@@ -2171,6 +2233,16 @@
tgUser = loadSavedAuth();
renderHeaderAuth();
if (tgUser) loadHistory();
if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK)
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче
(function () {
const p = new URLSearchParams(location.search);
if (p.has('login')) {
const v = p.get('login');
try { history.replaceState({}, '', location.pathname); } catch (_) {}
if (v === 'error') showError(t('err_login'));
}
})();
// ── Drop zone ─────────────────────────────────────────────────────────────
const dropZone = document.getElementById('drop-zone');