Вход через Яндекс (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:
289
app/main.py
289
app/main.py
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user