Вход через Яндекс (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 fastapi.exceptions import RequestValidationError
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from starlette.middleware.base import BaseHTTPMiddleware 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 from fastapi.staticfiles import StaticFiles
# ── Config ──────────────────────────────────────────────────────────────────── # ── Config ────────────────────────────────────────────────────────────────────
@@ -48,6 +48,20 @@ PAY_MAX_RUB = 100000.0
# Тариф API Pro (правится здесь). Активируется при purpose=api_pro. # Тариф API Pro (правится здесь). Активируется при purpose=api_pro.
API_PRO_PRICE_RUB = float(os.getenv("API_PRO_PRICE_RUB", "299")) API_PRO_PRICE_RUB = float(os.getenv("API_PRO_PRICE_RUB", "299"))
API_PRO_DAYS = 30 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 МБ) # Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
@@ -242,7 +256,27 @@ def _db_ensure_auth_table():
last_req REAL 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(""" con.execute("""
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS ( CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
payment_id TEXT PRIMARY KEY, payment_id TEXT PRIMARY KEY,
@@ -811,16 +845,24 @@ def _finalize_submit(job_id, file_path, original_name, stitching, title,
numeration, flip_side, color, pdfa, quality, numeration, flip_side, color, pdfa, quality,
size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None): size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None):
"""Общий хвост постановки задания: проверка авторизации, запись в БД, _meta. """Общий хвост постановки задания: проверка авторизации, запись в БД, _meta.
trusted=True (бизнес-API по ключу) — пропускает порог авторизации по размеру. trusted=True (бизнес-API по ключу) — пропускает пейволл.
web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id).""" 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): if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES):
if _db_sub_active(_account_id(user_id)):
pass # активная подписка — бесплатно
elif PAYWALL_ENABLED:
held = True # держим до оплаты 99₽ или подписки
else:
# Пейволл ещё не включён → прежний порог: нужна TG-авторизация
verified = False verified = False
if user_id: if user_id:
with _verified_users_lock: with _verified_users_lock:
ts = _verified_users.get(user_id) ts = _verified_users.get(user_id)
if ts and time.time() - ts <= 86400: if ts and time.time() - ts <= 86400:
verified = True verified = True
# Фоллбэк на БД — авторизация переживает рестарт контейнера
if not verified and _db_is_verified(user_id): if not verified and _db_is_verified(user_id):
verified = True verified = True
if not verified: if not verified:
@@ -857,6 +899,11 @@ def _finalize_submit(job_id, file_path, original_name, stitching, title,
"stitching": stitching, "stitching": stitching,
"created_at": time.time(), "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} return {"job_id": job_id}
@@ -1031,6 +1078,11 @@ async def status(job_id: str):
if url_res_f in ("processing", "uploading"): if url_res_f in ("processing", "uploading"):
return {"status": "processing", "progress": 50, "queue_pos": 0, "error": None} 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"): if url_res_f in ("ошибка", "error"):
return {"status": "error", "progress": 0, "queue_pos": 0, "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}") 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: try:
with sqlite3.connect(DB_PATH, timeout=10) as con: with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute("UPDATE WEB_PAYMENTS SET status='succeeded', paid_at=? WHERE payment_id=?", 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=?", con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?",
(until, api_key)) (until, api_key))
con.commit() 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: except Exception as e:
print(f"mark paid error: {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] purpose = str(payload.get("purpose") or "donate")[:32]
email = (payload.get("email") or "").strip()[:128] email = (payload.get("email") or "").strip()[:128]
api_key = (payload.get("api_key") or "").strip()[:80] or None 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": if purpose == "api_pro":
amount = API_PRO_PRICE_RUB amount = API_PRO_PRICE_RUB
description = "pdf2scan API — тариф Business (30 дней)" description = "pdf2scan API — тариф Business (30 дней)"
if api_key and not _db_get_api_key(api_key): if api_key and not _db_get_api_key(api_key):
raise HTTPException(status_code=400, detail="Неизвестный API-ключ") 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: else:
try: try:
amount = round(float(payload.get("amount") or 0), 2) amount = round(float(payload.get("amount") or 0), 2)
@@ -1584,7 +1697,8 @@ async def pay_create(payload: dict):
"capture": True, "capture": True,
"confirmation": {"type": "redirect", "return_url": f"{SITE_BASE_URL}/?paid={purpose}"}, "confirmation": {"type": "redirect", "return_url": f"{SITE_BASE_URL}/?paid={purpose}"},
"description": description, "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) if email: # чек 54-ФЗ (нужна подключённая онлайн-касса в ЮKassa)
body["receipt"] = { body["receipt"] = {
@@ -1620,7 +1734,7 @@ async def yookassa_webhook(payload: dict):
amount = float((real.get("amount") or {}).get("value") or 0) amount = float((real.get("amount") or {}).get("value") or 0)
_db_record_payment(pid, amount, "RUB", status, purpose, api_key, meta.get("email") or "") _db_record_payment(pid, amount, "RUB", status, purpose, api_key, meta.get("email") or "")
if status == "succeeded": if status == "succeeded":
_db_mark_payment_paid(pid, purpose, api_key) _db_mark_payment_paid(pid, purpose, api_key, extra=meta)
return {"ok": True} return {"ok": True}
@@ -1651,12 +1765,143 @@ async def pay_status(payment_id: str):
rk = meta.get("api_key") or api_key rk = meta.get("api_key") or api_key
_db_record_payment(payment_id, amount, "RUB", status, rp, rk, meta.get("email") or "") _db_record_payment(payment_id, amount, "RUB", status, rp, rk, meta.get("email") or "")
if status == "succeeded": 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} return {"status": status or db_status, "purpose": purpose}
except Exception: except Exception:
return {"status": db_status, "purpose": purpose} 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> _API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@@ -847,15 +847,25 @@
<div class="logo-subtitle" data-i18n="logo_subtitle">Создание растровых pdf с эффектом сканирования из электронных pdf документов</div> <div class="logo-subtitle" data-i18n="logo_subtitle">Создание растровых pdf с эффектом сканирования из электронных pdf документов</div>
</div> </div>
<div id="header-auth"> <div id="header-auth" style="position:relative;">
<button id="tg-login-header-btn" onclick="startTgAuth()" style="display:none;"> <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> <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> <span data-i18n="login_btn">Войти</span>
</button> </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;"> <div id="tg-user-info" style="display:none;">
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'"> <img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
<span id="tg-user-name"></span> <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>
</div> </div>
</header> </header>
@@ -1132,6 +1142,9 @@
page_title: 'pdf2scan — онлайн сканирование PDF', page_title: 'pdf2scan — онлайн сканирование PDF',
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов', logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
login_btn: 'Войти', login_btn: 'Войти',
login_via_tg: 'Через Telegram',
login_via_yandex: 'Через Яндекс',
err_login: 'Не удалось войти. Попробуйте снова.',
logout_btn: 'Выйти', logout_btn: 'Выйти',
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.', page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
card_file: 'Файл', card_file: 'Файл',
@@ -1235,6 +1248,9 @@
page_title: 'pdf2scan — online PDF scan', page_title: 'pdf2scan — online PDF scan',
logo_subtitle: 'Convert PDF to scanned document online', logo_subtitle: 'Convert PDF to scanned document online',
login_btn: 'Login', login_btn: 'Login',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Login failed. Please try again.',
logout_btn: 'Logout', logout_btn: 'Logout',
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.', page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
card_file: 'File', card_file: 'File',
@@ -1339,6 +1355,9 @@
page_title: 'pdf2scan — Online-PDF-Scan', page_title: 'pdf2scan — Online-PDF-Scan',
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln', logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
login_btn: 'Anmelden', login_btn: 'Anmelden',
login_via_tg: 'Über Telegram',
login_via_yandex: 'Über Yandex',
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
logout_btn: 'Abmelden', logout_btn: 'Abmelden',
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.', page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
card_file: 'Datei', card_file: 'Datei',
@@ -1443,6 +1462,9 @@
page_title: 'pdf2scan — scan PDF en ligne', page_title: 'pdf2scan — scan PDF en ligne',
logo_subtitle: 'Convertir des PDF en documents scannés en ligne', logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
login_btn: 'Connexion', login_btn: 'Connexion',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Échec de connexion. Réessayez.',
logout_btn: 'Déconnexion', 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.', 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', card_file: 'Fichier',
@@ -1547,6 +1569,9 @@
page_title: 'pdf2scan — scansione PDF online', page_title: 'pdf2scan — scansione PDF online',
logo_subtitle: 'Converti PDF in documenti scansionati online', logo_subtitle: 'Converti PDF in documenti scansionati online',
login_btn: 'Accedi', login_btn: 'Accedi',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Accesso non riuscito. Riprova.',
logout_btn: 'Esci', logout_btn: 'Esci',
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.', page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
card_file: 'File', card_file: 'File',
@@ -1651,6 +1676,9 @@
page_title: 'pdf2scan — escaneo PDF en línea', page_title: 'pdf2scan — escaneo PDF en línea',
logo_subtitle: 'Convierte PDF en documentos escaneados en línea', logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
login_btn: 'Iniciar sesión', 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', 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.', page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
card_file: 'Archivo', card_file: 'Archivo',
@@ -1755,6 +1783,9 @@
page_title: 'pdf2scan — digitalização PDF online', page_title: 'pdf2scan — digitalização PDF online',
logo_subtitle: 'Converta PDF em documentos digitalizados online', logo_subtitle: 'Converta PDF em documentos digitalizados online',
login_btn: 'Entrar', login_btn: 'Entrar',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
err_login: 'Falha no login. Tente novamente.',
logout_btn: 'Sair', 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.', 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', card_file: 'Arquivo',
@@ -2103,22 +2134,53 @@
} catch (e) { /* тихо */ } } catch (e) { /* тихо */ }
} }
let sessionUser = null; // из /api/me (вход через Яндекс/VK)
function renderHeaderAuth() { function renderHeaderAuth() {
const loginBtn = document.getElementById('tg-login-header-btn'); const loginBtn = document.getElementById('tg-login-header-btn');
const userInfo = document.getElementById('tg-user-info'); 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'; loginBtn.style.display = 'none';
userInfo.style.display = 'flex'; userInfo.style.display = 'flex';
document.getElementById('tg-user-name').textContent = document.getElementById('tg-user-name').textContent = name;
tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : ''); document.getElementById('tg-user-photo').style.display = 'none';
const photo = document.getElementById('tg-user-photo');
photo.style.display = 'none';
} else { } else {
loginBtn.style.display = 'flex'; loginBtn.style.display = 'flex';
userInfo.style.display = 'none'; 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 _authPollInterval = null;
let _authToken = null; let _authToken = null;
@@ -2171,6 +2233,16 @@
tgUser = loadSavedAuth(); tgUser = loadSavedAuth();
renderHeaderAuth(); renderHeaderAuth();
if (tgUser) loadHistory(); 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 ───────────────────────────────────────────────────────────── // ── Drop zone ─────────────────────────────────────────────────────────────
const dropZone = document.getElementById('drop-zone'); const dropZone = document.getElementById('drop-zone');