diff --git a/app/main.py b/app/main.py index 5a101b6..0a22658 100644 --- a/app/main.py +++ b/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:' (позже 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 = """ diff --git a/app/static/index.html b/app/static/index.html index a916ee1..c082678 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -847,15 +847,25 @@
Создание растровых pdf с эффектом сканирования из электронных pdf документов
-
- +
@@ -1132,6 +1142,9 @@ page_title: 'pdf2scan — онлайн сканирование PDF', logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов', login_btn: 'Войти', + login_via_tg: 'Через Telegram', + login_via_yandex: 'Через Яндекс', + err_login: 'Не удалось войти. Попробуйте снова.', logout_btn: 'Выйти', page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.', card_file: 'Файл', @@ -1235,6 +1248,9 @@ page_title: 'pdf2scan — online PDF scan', logo_subtitle: 'Convert PDF to scanned document online', login_btn: 'Login', + login_via_tg: 'Via Telegram', + login_via_yandex: 'Via Yandex', + err_login: 'Login failed. Please try again.', logout_btn: 'Logout', page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.', card_file: 'File', @@ -1339,6 +1355,9 @@ page_title: 'pdf2scan — Online-PDF-Scan', logo_subtitle: 'PDF online in gescannte Dokumente umwandeln', login_btn: 'Anmelden', + login_via_tg: 'Über Telegram', + login_via_yandex: 'Über Yandex', + err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.', logout_btn: 'Abmelden', page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.', card_file: 'Datei', @@ -1443,6 +1462,9 @@ page_title: 'pdf2scan — scan PDF en ligne', logo_subtitle: 'Convertir des PDF en documents scannés en ligne', login_btn: 'Connexion', + login_via_tg: 'Via Telegram', + login_via_yandex: 'Via Yandex', + err_login: 'Échec de connexion. Réessayez.', logout_btn: 'Déconnexion', page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.', card_file: 'Fichier', @@ -1547,6 +1569,9 @@ page_title: 'pdf2scan — scansione PDF online', logo_subtitle: 'Converti PDF in documenti scansionati online', login_btn: 'Accedi', + login_via_tg: 'Via Telegram', + login_via_yandex: 'Via Yandex', + err_login: 'Accesso non riuscito. Riprova.', logout_btn: 'Esci', page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.', card_file: 'File', @@ -1651,6 +1676,9 @@ page_title: 'pdf2scan — escaneo PDF en línea', logo_subtitle: 'Convierte PDF en documentos escaneados en línea', login_btn: 'Iniciar sesión', + login_via_tg: 'Con Telegram', + login_via_yandex: 'Con Yandex', + err_login: 'Error de inicio de sesión. Inténtalo de nuevo.', logout_btn: 'Cerrar sesión', page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.', card_file: 'Archivo', @@ -1755,6 +1783,9 @@ page_title: 'pdf2scan — digitalização PDF online', logo_subtitle: 'Converta PDF em documentos digitalizados online', login_btn: 'Entrar', + login_via_tg: 'Via Telegram', + login_via_yandex: 'Via Yandex', + err_login: 'Falha no login. Tente novamente.', logout_btn: 'Sair', page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.', card_file: 'Arquivo', @@ -2103,22 +2134,53 @@ } catch (e) { /* тихо */ } } + let sessionUser = null; // из /api/me (вход через Яндекс/VK) + function renderHeaderAuth() { const loginBtn = document.getElementById('tg-login-header-btn'); const userInfo = document.getElementById('tg-user-info'); - if (tgUser) { + const name = tgUser + ? (tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : '')) + : (sessionUser ? sessionUser.name : null); + if (name) { loginBtn.style.display = 'none'; userInfo.style.display = 'flex'; - document.getElementById('tg-user-name').textContent = - tgUser.first_name + (tgUser.last_name ? ' ' + tgUser.last_name : ''); - const photo = document.getElementById('tg-user-photo'); - photo.style.display = 'none'; + document.getElementById('tg-user-name').textContent = name; + document.getElementById('tg-user-photo').style.display = 'none'; } else { loginBtn.style.display = 'flex'; userInfo.style.display = 'none'; } } + // ── Меню входа (Telegram / Яндекс) ──────────────────────────────────────── + function toggleLoginMenu(e) { + e.stopPropagation(); + const m = document.getElementById('login-menu'); + m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none'; + } + function closeLoginMenu() { + const m = document.getElementById('login-menu'); + if (m) m.style.display = 'none'; + } + document.addEventListener('click', closeLoginMenu); + + async function doLogout() { + try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {} + sessionUser = null; + logoutTg(); // чистит localStorage TG + перерисовывает шапку + } + + async function loadMe() { + try { + const r = await fetch('/api/me'); + const d = await r.json(); + sessionUser = d.logged_in ? d : null; + } catch (e) { sessionUser = null; } + renderHeaderAuth(); + if (sessionUser) loadHistory(); + } + // ── Авторизация через бота ──────────────────────────────────────────────── let _authPollInterval = null; let _authToken = null; @@ -2171,6 +2233,16 @@ tgUser = loadSavedAuth(); renderHeaderAuth(); if (tgUser) loadHistory(); + if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK) + // Возврат после OAuth: чистим ?login и показываем ошибку при неудаче + (function () { + const p = new URLSearchParams(location.search); + if (p.has('login')) { + const v = p.get('login'); + try { history.replaceState({}, '', location.pathname); } catch (_) {} + if (v === 'error') showError(t('err_login')); + } + })(); // ── Drop zone ───────────────────────────────────────────────────────────── const dropZone = document.getElementById('drop-zone');