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