Вход через Яндекс (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>