- «По дням»: колонка «Ср/стр» (сумма elapsed по дню / страницы дня) — динамика скорости - «Обработанные документы»: колонка «Время» (elapsed_time файла) + в CSV - Общий парсер _elapsed_to_sec (H:MM:SS.ffffff → сек), переиспользован в итогах/дне/файле Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3129 lines
152 KiB
Python
3129 lines
152 KiB
Python
"""
|
||
pdf2scan web service — FastAPI backend.
|
||
Добавляет задания в очередь бота (та же БД, тот же temp/).
|
||
Бот обрабатывает и оставляет файл в temp/ (url_res_f='web_local').
|
||
Веб отдаёт файл напрямую и удаляет через 30 минут.
|
||
"""
|
||
|
||
import asyncio
|
||
import datetime
|
||
import hashlib
|
||
import hmac
|
||
import os
|
||
import re
|
||
import secrets
|
||
import sqlite3
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
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, RedirectResponse, Response
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
# ── Config ────────────────────────────────────────────────────────────────────
|
||
BOT_TOKEN = os.getenv("TOKEN", "")
|
||
BOT_USERNAME = os.getenv("BOT_USERNAME", "pdf2scan_pybot")
|
||
DB_PATH = "/data/bot_db.db"
|
||
BOT_TEMP = "temp/" # относительный путь (рабочая директория /app)
|
||
WEB_USER_ID = 0 # фейковый TG user_id для веб-заданий
|
||
RESULT_TTL = 21600 # 6 часов (большие пачки не успевают за 30 мин)
|
||
AUTH_TOKEN_TTL = 600 # 10 минут на авторизацию через бота
|
||
|
||
AUTH_REQUIRED_PAGES = 100
|
||
AUTH_REQUIRED_MB = 50.0
|
||
|
||
# ── ЮKassa (платежи) ──────────────────────────────────────────────────────────
|
||
YOOKASSA_SHOP_ID = os.getenv("YOOKASSA_SHOP_ID", "")
|
||
YOOKASSA_SECRET_KEY = os.getenv("YOOKASSA_SECRET_KEY", "")
|
||
YOOKASSA_API = "https://api.yookassa.ru/v3"
|
||
SITE_BASE_URL = os.getenv("SITE_BASE_URL", "https://pdf2scan.online")
|
||
PAY_MIN_RUB = 10.0
|
||
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
|
||
# ── OAuth: VK ID (OAuth 2.1 / PKCE) ───────────────────────────────────────────
|
||
VK_CLIENT_ID = os.getenv("VK_CLIENT_ID", "")
|
||
VK_CLIENT_SECRET = os.getenv("VK_CLIENT_SECRET", "")
|
||
VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/vk/callback"
|
||
# ── Админ-панель ──────────────────────────────────────────────────────────────
|
||
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "")
|
||
|
||
# ── Подписка через Telegram-бота (подписанные deep-link) ──────────────────────
|
||
SUB_LINK_SECRET = os.getenv("SUB_LINK_SECRET", "") # общий секрет бота и веба
|
||
|
||
# ── Уведомления о платежах в Telegram ─────────────────────────────────────────
|
||
BOT_TOKEN = os.getenv("TOKEN", "")
|
||
PAYMENT_NOTIFY_CHAT_ID = int(os.getenv("PAYMENT_NOTIFY_CHAT_ID", "156921693"))
|
||
|
||
# ── Вход по почте (magic-code через SMTP) ─────────────────────────────────────
|
||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
|
||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
||
SMTP_FROM = os.getenv("SMTP_FROM", "") or SMTP_USER
|
||
EMAIL_CODE_TTL = 600 # 10 минут на ввод кода
|
||
|
||
# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
|
||
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
|
||
MAX_CHUNK_BYTES = 12 * 1024 * 1024 # защита: один кусок не больше 12 МБ
|
||
MAX_UPLOAD_BYTES = 300 * 1024 * 1024 # суммарный предел собранного файла
|
||
|
||
_COLOR_MAP = {"color": "цветной", "bw": "черно-белый"}
|
||
|
||
# job_id → {original_name, stitching, created_at}
|
||
_meta: dict[str, dict] = {}
|
||
_meta_lock = threading.Lock()
|
||
|
||
# zip_id → {status, path, name, created_at} — асинхронная сборка больших ZIP
|
||
_zip_jobs: dict[str, dict] = {}
|
||
_zip_jobs_lock = threading.Lock()
|
||
|
||
# user_id → unix timestamp of verification (24h TTL)
|
||
_verified_users: dict[int, float] = {}
|
||
_verified_users_lock = threading.Lock()
|
||
|
||
|
||
# ── Конвертация документов ────────────────────────────────────────────────────
|
||
_LO_PROFILE_TEMPLATE = "/opt/lo-profile"
|
||
|
||
def _convert_doc_to_pdf(src_path: str, dst_path: str) -> None:
|
||
"""Конвертирует DOC/DOCX в PDF через libreoffice."""
|
||
import tempfile, shutil
|
||
with tempfile.TemporaryDirectory() as userdir:
|
||
# Копируем шаблон профиля (замена шрифтов Calibri→Carlito и т.д.)
|
||
if os.path.isdir(_LO_PROFILE_TEMPLATE):
|
||
shutil.copytree(_LO_PROFILE_TEMPLATE, userdir, dirs_exist_ok=True)
|
||
cmd = [
|
||
"libreoffice",
|
||
f"-env:UserInstallation=file://{userdir}",
|
||
"--headless", "--norestore",
|
||
"--convert-to", "pdf",
|
||
"--outdir", str(Path(dst_path).parent), str(src_path),
|
||
]
|
||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"libreoffice convert failed: {result.stderr.decode(errors='ignore')}")
|
||
candidate = Path(dst_path).parent / (Path(src_path).stem + ".pdf")
|
||
if candidate.exists() and str(candidate) != dst_path:
|
||
candidate.rename(dst_path)
|
||
if not Path(dst_path).exists():
|
||
raise FileNotFoundError(f"Converted PDF not found: {dst_path}")
|
||
|
||
|
||
# ── БД-хелперы ────────────────────────────────────────────────────────────────
|
||
def _db_insert_job(
|
||
name_or_f, stitching, title, numeration, flip_side,
|
||
color, pdfa, quality, original_name, source_size_mb,
|
||
ocr=0, web_owner_id=0,
|
||
):
|
||
now = datetime.datetime.now()
|
||
date_str = now.strftime("%Y.%m.%d")
|
||
time_str = now.strftime("%H:%M:%S")
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute("PRAGMA table_info(FILES)")
|
||
cols = {row[1] for row in cur.fetchall()}
|
||
|
||
fields = [
|
||
"date", "time", "name_or_f", "user_id",
|
||
"first_name", "last_name", "username",
|
||
"stitching", "title", "numeration", "flip_side",
|
||
"color", "pdfa", "num_pages", "name_res_f",
|
||
"url_res_f", "finish_time", "elapsed_time",
|
||
]
|
||
values = [
|
||
date_str, time_str, name_or_f, WEB_USER_ID,
|
||
"Web", "User", "webuser",
|
||
stitching, int(title), int(numeration), int(flip_side),
|
||
color, int(pdfa), 0, "-",
|
||
"-", "-", "-",
|
||
]
|
||
|
||
if "original_name" in cols:
|
||
fields.append("original_name")
|
||
values.append(original_name or "document.pdf")
|
||
if "source_size_mb" in cols:
|
||
fields.append("source_size_mb")
|
||
values.append(source_size_mb)
|
||
if "quality" in cols:
|
||
fields.append("quality")
|
||
values.append(quality or "high")
|
||
if "ocr" in cols:
|
||
fields.append("ocr")
|
||
values.append(int(ocr))
|
||
if "web_owner_id" in cols:
|
||
fields.append("web_owner_id")
|
||
values.append(int(web_owner_id or 0))
|
||
|
||
placeholders = ", ".join(["?"] * len(fields))
|
||
sql = f"INSERT INTO FILES ({', '.join(fields)}) VALUES ({placeholders})"
|
||
cur.execute(sql, values)
|
||
con.commit()
|
||
|
||
|
||
def _db_get_url_res_f(name_or_f: str) -> str | None:
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute("SELECT url_res_f FROM FILES WHERE name_or_f = ?", (name_or_f,))
|
||
row = cur.fetchone()
|
||
return row[0] if row else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _db_get_job_meta(name_or_f: str):
|
||
"""(original_name, stitching) из FILES — имена для скачивания/ZIP переживают рестарт."""
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute("SELECT original_name, stitching FROM FILES WHERE name_or_f = ?", (name_or_f,))
|
||
row = cur.fetchone()
|
||
if row:
|
||
return (row[0] or None, row[1] or None)
|
||
except Exception:
|
||
pass
|
||
return (None, None)
|
||
|
||
|
||
def _db_set_url_res_f(name_or_f: str, value: str):
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute("UPDATE FILES SET url_res_f = ? WHERE name_or_f = ?", (value, name_or_f))
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _db_queue_position(name_or_f: str) -> int:
|
||
"""Реальная позиция задания в очереди (1 = бот обрабатывает его сейчас; FIFO по rowid).
|
||
Бот не помечает задание 'processing' в БД, поэтому позицию считаем по rowid."""
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute("SELECT rowid FROM FILES WHERE name_or_f = ?", (name_or_f,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return 0
|
||
myrow = row[0]
|
||
cur.execute(
|
||
"SELECT COUNT(*) FROM FILES "
|
||
"WHERE (url_res_f IS NULL OR TRIM(url_res_f) IN ('', '-')) AND rowid <= ?",
|
||
(myrow,),
|
||
)
|
||
r = cur.fetchone()
|
||
return int(r[0]) if r else 0
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
# ── Web auth tokens (shared SQLite) ───────────────────────────────────────────
|
||
|
||
def _db_ensure_auth_table():
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute("""
|
||
CREATE TABLE IF NOT EXISTS WEB_AUTH_TOKENS (
|
||
token TEXT PRIMARY KEY,
|
||
user_id INTEGER,
|
||
first_name TEXT,
|
||
last_name TEXT,
|
||
username TEXT,
|
||
created_at REAL,
|
||
status TEXT DEFAULT 'pending'
|
||
)
|
||
""")
|
||
# Подтверждённые веб-пользователи — в БД, чтобы авторизация
|
||
# переживала рестарт контейнера (не только in-memory _verified_users).
|
||
con.execute("""
|
||
CREATE TABLE IF NOT EXISTS WEB_VERIFIED_USERS (
|
||
user_id INTEGER PRIMARY KEY,
|
||
verified_at REAL
|
||
)
|
||
""")
|
||
# API-ключи для бизнес-доступа (интеграции). owner_id — TG user_id
|
||
# владельца ключа (из веб-авторизации). Оплату/квоты прикрутим позже —
|
||
# поля plan/quota уже заложены.
|
||
con.execute("""
|
||
CREATE TABLE IF NOT EXISTS WEB_API_KEYS (
|
||
api_key TEXT PRIMARY KEY,
|
||
owner_id INTEGER,
|
||
name TEXT,
|
||
plan TEXT DEFAULT 'free',
|
||
active INTEGER DEFAULT 1,
|
||
req_count INTEGER DEFAULT 0,
|
||
created_at REAL,
|
||
last_req REAL
|
||
)
|
||
""")
|
||
# Аккаунты с подпиской. 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
|
||
)
|
||
""")
|
||
# Коды для входа по почте (magic-code).
|
||
con.execute("""
|
||
CREATE TABLE IF NOT EXISTS WEB_EMAIL_CODES (
|
||
email TEXT PRIMARY KEY,
|
||
code TEXT,
|
||
created_at REAL,
|
||
attempts INTEGER DEFAULT 0
|
||
)
|
||
""")
|
||
# Счётчик заходов на сайт по дням (для админ-панели).
|
||
con.execute("""
|
||
CREATE TABLE IF NOT EXISTS WEB_VISITS (
|
||
day TEXT PRIMARY KEY,
|
||
count INTEGER DEFAULT 0
|
||
)
|
||
""")
|
||
# Уникальные посетители: (день, id браузера из cookie p2s_vid).
|
||
con.execute("""
|
||
CREATE TABLE IF NOT EXISTS WEB_VISIT_IDS (
|
||
day TEXT,
|
||
vid TEXT,
|
||
PRIMARY KEY (day, vid)
|
||
)
|
||
""")
|
||
# Платежи Ю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,
|
||
amount REAL,
|
||
currency TEXT,
|
||
status TEXT,
|
||
purpose TEXT,
|
||
api_key TEXT,
|
||
owner_id INTEGER,
|
||
email TEXT,
|
||
created_at REAL,
|
||
paid_at REAL
|
||
)
|
||
""")
|
||
# paid_until у ключей — до какого времени активен платный тариф
|
||
try:
|
||
cur = con.execute("PRAGMA table_info(WEB_API_KEYS)")
|
||
kcols = {row[1] for row in cur.fetchall()}
|
||
if "paid_until" not in kcols:
|
||
con.execute("ALTER TABLE WEB_API_KEYS ADD COLUMN paid_until REAL DEFAULT 0")
|
||
except Exception as _kmig:
|
||
print(f"WEB_API_KEYS migrate error: {_kmig}")
|
||
# Авто-миграция FILES: колонки для OCR и владельца веб-задания
|
||
# (личный кабинет «Мои документы»). ADD COLUMN идемпотентен по факту наличия.
|
||
try:
|
||
cur = con.execute("PRAGMA table_info(FILES)")
|
||
have = {row[1] for row in cur.fetchall()}
|
||
if "ocr" not in have:
|
||
con.execute("ALTER TABLE FILES ADD COLUMN ocr INTEGER DEFAULT 0")
|
||
if "web_owner_id" not in have:
|
||
con.execute("ALTER TABLE FILES ADD COLUMN web_owner_id INTEGER DEFAULT 0")
|
||
except Exception as _mig:
|
||
print(f"FILES migrate error: {_mig}")
|
||
# Авто-миграция WEB_ACCOUNTS: авто-продление подписки (сохранённый способ оплаты)
|
||
try:
|
||
cur = con.execute("PRAGMA table_info(WEB_ACCOUNTS)")
|
||
acols = {row[1] for row in cur.fetchall()}
|
||
for _c, _ddl in (("autorenew", "INTEGER DEFAULT 0"), ("pm_id", "TEXT"),
|
||
("pm_email", "TEXT"), ("renew_fail", "INTEGER DEFAULT 0"),
|
||
("last_renew", "REAL DEFAULT 0")):
|
||
if _c not in acols:
|
||
con.execute(f"ALTER TABLE WEB_ACCOUNTS ADD COLUMN {_c} {_ddl}")
|
||
except Exception as _amig:
|
||
print(f"WEB_ACCOUNTS migrate error: {_amig}")
|
||
# Авто-миграция WEB_PAYMENTS: флаг отправленного уведомления (дедуп)
|
||
try:
|
||
pcols = {row[1] for row in con.execute("PRAGMA table_info(WEB_PAYMENTS)")}
|
||
if "notified" not in pcols:
|
||
con.execute("ALTER TABLE WEB_PAYMENTS ADD COLUMN notified INTEGER DEFAULT 0")
|
||
except Exception as _pmig:
|
||
print(f"WEB_PAYMENTS migrate error: {_pmig}")
|
||
con.commit()
|
||
except Exception as e:
|
||
print(f"WEB_AUTH_TOKENS create error: {e}")
|
||
|
||
|
||
def _db_mark_verified(user_id: int):
|
||
if not user_id:
|
||
return
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"INSERT INTO WEB_VERIFIED_USERS (user_id, verified_at) VALUES (?, ?) "
|
||
"ON CONFLICT(user_id) DO UPDATE SET verified_at=excluded.verified_at",
|
||
(int(user_id), time.time()),
|
||
)
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _db_is_verified(user_id: int) -> bool:
|
||
if not user_id:
|
||
return False
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute("SELECT verified_at FROM WEB_VERIFIED_USERS WHERE user_id = ?", (int(user_id),))
|
||
row = cur.fetchone()
|
||
return bool(row) and (time.time() - (row[0] or 0) <= 86400)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _db_create_auth_token(token: str):
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"INSERT INTO WEB_AUTH_TOKENS (token, created_at, status) VALUES (?, ?, 'pending')",
|
||
(token, time.time()),
|
||
)
|
||
con.commit()
|
||
|
||
|
||
def _db_poll_auth_token(token: str) -> dict | None:
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute(
|
||
"SELECT status, user_id, first_name, last_name, username, created_at "
|
||
"FROM WEB_AUTH_TOKENS WHERE token = ?",
|
||
(token,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return None
|
||
return {
|
||
"status": row[0], "user_id": row[1],
|
||
"first_name": row[2], "last_name": row[3],
|
||
"username": row[4], "created_at": row[5],
|
||
}
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ── Фоновая уборка файлов через 30 минут ──────────────────────────────────────
|
||
def _cleanup_loop():
|
||
while True:
|
||
time.sleep(60)
|
||
now = time.time()
|
||
# Удаляем просроченные сессии авторизации (24 ч)
|
||
with _verified_users_lock:
|
||
stale = [uid for uid, ts in _verified_users.items() if now - ts > 86400]
|
||
for uid in stale:
|
||
del _verified_users[uid]
|
||
# Удаляем старые auth-токены из БД (старше 1 часа)
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=5) as con:
|
||
con.execute(
|
||
"DELETE FROM WEB_AUTH_TOKENS WHERE created_at < ?",
|
||
(now - 3600,),
|
||
)
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
# Удаляем старые результаты по mtime файла — не зависит от in-memory _meta,
|
||
# поэтому переживает рестарт контейнера (пачка не пропадает).
|
||
try:
|
||
for rp in Path(BOT_TEMP).glob("* скан *.pdf"):
|
||
try:
|
||
if now - rp.stat().st_mtime > RESULT_TTL:
|
||
rp.unlink()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
with _meta_lock:
|
||
for jid in [j for j, m in _meta.items() if now - m["created_at"] > RESULT_TTL]:
|
||
_meta.pop(jid, None)
|
||
# Подчищаем оставшиеся ZIP-архивы (на случай, если их не скачали)
|
||
try:
|
||
for zp in Path(BOT_TEMP).glob("*.zip"):
|
||
if now - zp.stat().st_mtime > RESULT_TTL:
|
||
zp.unlink()
|
||
except Exception:
|
||
pass
|
||
# Подчищаем устаревшие записи фоновых zip-сборок
|
||
with _zip_jobs_lock:
|
||
for zid in [z for z, jj in _zip_jobs.items() if now - jj.get("created_at", now) > RESULT_TTL]:
|
||
old = _zip_jobs.pop(zid, {})
|
||
if old.get("path"):
|
||
_safe_remove(old["path"])
|
||
# Подчищаем брошенные куски чанковой загрузки (старше часа)
|
||
try:
|
||
for pp in Path(UPLOAD_DIR).glob("*.part"):
|
||
if now - pp.stat().st_mtime > 3600:
|
||
pp.unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||
def _verify_telegram_auth(data: dict) -> bool:
|
||
if not BOT_TOKEN:
|
||
return False
|
||
try:
|
||
data = dict(data)
|
||
check_hash = data.pop("hash", None)
|
||
if not check_hash:
|
||
return False
|
||
if time.time() - int(data.get("auth_date", 0)) > 86400:
|
||
return False
|
||
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items()))
|
||
secret_key = hashlib.sha256(BOT_TOKEN.encode()).digest()
|
||
expected = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||
return hmac.compare_digest(expected, check_hash)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# ── App ───────────────────────────────────────────────────────────────────────
|
||
app = FastAPI(title="pdf2scan web")
|
||
|
||
|
||
class NoIndexApiMiddleware(BaseHTTPMiddleware):
|
||
"""Запрещает индексирование /api/* роботами."""
|
||
async def dispatch(self, request: Request, call_next):
|
||
response = await call_next(request)
|
||
if request.url.path.startswith("/api/"):
|
||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||
return response
|
||
|
||
app.add_middleware(NoIndexApiMiddleware)
|
||
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||
print(f"422 on {request.method} {request.url.path}: {exc.errors()}")
|
||
return JSONResponse(status_code=422, content={"detail": str(exc.errors())})
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def _startup():
|
||
os.makedirs(BOT_TEMP, exist_ok=True)
|
||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||
_db_ensure_auth_table()
|
||
threading.Thread(target=_cleanup_loop, daemon=True).start()
|
||
threading.Thread(target=_autorenew_loop, daemon=True).start()
|
||
|
||
|
||
app.mount("/static", StaticFiles(directory="/web_app/static"), name="static")
|
||
|
||
|
||
# ── SEO: серверный рендер локализованных языковых страниц ─────────────────────
|
||
# Отдельные URL на язык (/, /en/, /de/…) с локализованным <head> — краулеры видят
|
||
# правильные title/description/hreflang на своём языке, это открывает западный трафик.
|
||
SEO_BASE = "https://pdf2scan.online"
|
||
SEO_LANG_PATH = {
|
||
"ru": "/", "en": "/en/", "de": "/de/", "fr": "/fr/",
|
||
"it": "/it/", "es": "/es/", "pt": "/pt/",
|
||
}
|
||
SEO_LOCALE = {
|
||
"ru": "ru_RU", "en": "en_US", "de": "de_DE", "fr": "fr_FR",
|
||
"it": "it_IT", "es": "es_ES", "pt": "pt_PT",
|
||
}
|
||
SEO = {
|
||
"ru": {
|
||
"title": "pdf2scan — сделать скан из PDF онлайн бесплатно",
|
||
"desc": "Сделайте скан из PDF онлайн — бесплатно, без регистрации. Эффект сканирования, сшивка (нитка, ленточка, пружинка, уголок, дырокол), нумерация страниц и лист «прошито-пронумеровано».",
|
||
"og_desc": "Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.",
|
||
"keywords": "сделать скан из pdf онлайн, pdf в скан онлайн, сканирование pdf бесплатно, pdf с эффектом сканирования, прошить документ онлайн, сшивка документов онлайн, растровый pdf онлайн, pdf2scan",
|
||
"ld_desc": "Бесплатный онлайн-инструмент для создания PDF с эффектом сканирования. Сшивка нитками, ленточкой, уголком, пружинкой и дыроколом. Нумерация страниц, титульный лист, PDF/A.",
|
||
},
|
||
"en": {
|
||
"title": "pdf2scan — add a scan effect to PDF online, free",
|
||
"desc": "Make a PDF look scanned online — free, no signup. Add a realistic scan effect, binding (thread, ribbon, spiral, corner, hole punch), page numbering and a certification page. Word to scanned PDF too.",
|
||
"og_desc": "Make your PDF look scanned online — free, no signup. Scan effect, binding and page numbering in one click.",
|
||
"keywords": "pdf scanner effect online, make pdf look scanned, add scan effect to pdf, convert pdf to scanned document, scan effect pdf free, pdf to scan online, word to scanned pdf, pdf2scan",
|
||
"ld_desc": "Free online tool to give a PDF a realistic scan effect. Thread, ribbon, corner, spiral and hole-punch binding. Page numbering, title page, PDF/A.",
|
||
},
|
||
"de": {
|
||
"title": "pdf2scan — PDF online mit Scan-Effekt versehen, kostenlos",
|
||
"desc": "PDF online wie gescannt aussehen lassen — kostenlos, ohne Anmeldung. Realistischer Scan-Effekt, Bindung (Faden, Band, Spirale, Ecke, Lochung), Seitennummerierung und Zertifizierungsseite. Auch Word zu gescanntem PDF.",
|
||
"og_desc": "PDF online wie gescannt aussehen lassen — kostenlos, ohne Anmeldung. Scan-Effekt, Bindung und Nummerierung in einem Klick.",
|
||
"keywords": "pdf scan-effekt online, pdf wie gescannt, pdf in scan umwandeln, scan-effekt pdf kostenlos, word in gescanntes pdf, pdf2scan",
|
||
"ld_desc": "Kostenloses Online-Tool, um ein PDF wie einen echten Scan aussehen zu lassen. Faden-, Band-, Ecken-, Spiral- und Lochbindung. Seitennummerierung, Titelseite, PDF/A.",
|
||
},
|
||
"fr": {
|
||
"title": "pdf2scan — ajouter un effet scan à un PDF en ligne, gratuit",
|
||
"desc": "Donnez à un PDF l'apparence d'un document scanné en ligne — gratuit, sans inscription. Effet scan réaliste, reliure (fil, ruban, spirale, coin, perforation), numérotation et page de certification. Word vers PDF scanné aussi.",
|
||
"og_desc": "Donnez à un PDF l'apparence d'un scan en ligne — gratuit, sans inscription. Effet scan, reliure et numérotation en un clic.",
|
||
"keywords": "effet scan pdf en ligne, pdf aspect scanné, convertir pdf en scan, effet scan pdf gratuit, word en pdf scanné, pdf2scan",
|
||
"ld_desc": "Outil en ligne gratuit pour donner à un PDF un effet de scan réaliste. Reliure par fil, ruban, coin, spirale et perforateur. Numérotation, page de titre, PDF/A.",
|
||
},
|
||
"it": {
|
||
"title": "pdf2scan — aggiungi un effetto scansione al PDF online, gratis",
|
||
"desc": "Fai sembrare scansionato un PDF online — gratis, senza registrazione. Effetto scansione realistico, rilegatura (filo, nastro, spirale, angolo, foratura), numerazione e pagina di certificazione. Anche da Word a PDF scansionato.",
|
||
"og_desc": "Fai sembrare scansionato un PDF online — gratis, senza registrazione. Effetto scansione, rilegatura e numerazione in un clic.",
|
||
"keywords": "effetto scansione pdf online, pdf aspetto scansionato, convertire pdf in scansione, effetto scansione pdf gratis, word in pdf scansionato, pdf2scan",
|
||
"ld_desc": "Strumento online gratuito per dare a un PDF un effetto scansione realistico. Rilegatura con filo, nastro, angolo, spirale e perforatore. Numerazione, pagina del titolo, PDF/A.",
|
||
},
|
||
"es": {
|
||
"title": "pdf2scan — añade efecto de escaneo a un PDF online, gratis",
|
||
"desc": "Haz que un PDF parezca escaneado online — gratis, sin registro. Efecto de escaneo realista, encuadernación (hilo, cinta, espiral, esquina, perforación), numeración y página de certificación. También de Word a PDF escaneado.",
|
||
"og_desc": "Haz que un PDF parezca escaneado online — gratis, sin registro. Efecto de escaneo, encuadernación y numeración en un clic.",
|
||
"keywords": "efecto escaneo pdf online, pdf aspecto escaneado, convertir pdf a escaneado, efecto escaneo pdf gratis, word a pdf escaneado, pdf2scan",
|
||
"ld_desc": "Herramienta online gratuita para dar a un PDF un efecto de escaneo realista. Encuadernación con hilo, cinta, esquina, espiral y perforadora. Numeración, página de título, PDF/A.",
|
||
},
|
||
"pt": {
|
||
"title": "pdf2scan — adicione efeito de digitalização ao PDF online, grátis",
|
||
"desc": "Faça um PDF parecer digitalizado online — grátis, sem cadastro. Efeito de digitalização realista, encadernação (fio, fita, espiral, canto, furação), numeração e página de certificação. Também de Word para PDF digitalizado.",
|
||
"og_desc": "Faça um PDF parecer digitalizado online — grátis, sem cadastro. Efeito de digitalização, encadernação e numeração em um clique.",
|
||
"keywords": "efeito digitalização pdf online, pdf aparência digitalizada, converter pdf em digitalizado, efeito scan pdf grátis, word para pdf digitalizado, pdf2scan",
|
||
"ld_desc": "Ferramenta online gratuita para dar a um PDF um efeito de digitalização realista. Encadernação com fio, fita, canto, espiral e furador. Numeração, página de título, PDF/A.",
|
||
},
|
||
}
|
||
|
||
_INDEX_RAW: str | None = None
|
||
|
||
|
||
def _load_index_template() -> str:
|
||
global _INDEX_RAW
|
||
if _INDEX_RAW is None:
|
||
with open("/web_app/static/index.html", encoding="utf-8") as f:
|
||
_INDEX_RAW = f.read()
|
||
return _INDEX_RAW
|
||
|
||
|
||
def _esc_attr(s: str) -> str:
|
||
return s.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
||
|
||
|
||
def _build_seo_head(lang: str) -> str:
|
||
s = SEO[lang]
|
||
path = SEO_LANG_PATH[lang]
|
||
url = SEO_BASE + path
|
||
title = _esc_attr(s["title"])
|
||
desc = _esc_attr(s["desc"])
|
||
og_desc = _esc_attr(s["og_desc"])
|
||
keywords = _esc_attr(s["keywords"])
|
||
og_img = f"{SEO_BASE}/static/og.png"
|
||
|
||
hreflangs = "\n".join(
|
||
f'<link rel="alternate" hreflang="{lc}" href="{SEO_BASE}{p}">'
|
||
for lc, p in SEO_LANG_PATH.items()
|
||
)
|
||
ld = {
|
||
"@context": "https://schema.org",
|
||
"@type": "WebApplication",
|
||
"name": "pdf2scan",
|
||
"url": url,
|
||
"description": s["ld_desc"],
|
||
"applicationCategory": "UtilitiesApplication",
|
||
"operatingSystem": "Any",
|
||
"browserRequirements": "Requires JavaScript",
|
||
"inLanguage": list(SEO_LANG_PATH.keys()),
|
||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "RUB"},
|
||
"creator": {"@type": "Organization", "name": "pdf2scan", "url": SEO_BASE},
|
||
}
|
||
import json as _json
|
||
ld_json = _json.dumps(ld, ensure_ascii=False, indent=2)
|
||
|
||
return (
|
||
f"<title>{title}</title>\n"
|
||
f'<meta name="description" content="{desc}">\n'
|
||
f'<meta name="keywords" content="{keywords}">\n'
|
||
f'<link rel="canonical" href="{url}">\n'
|
||
f"{hreflangs}\n"
|
||
f'<link rel="alternate" hreflang="x-default" href="{SEO_BASE}/">\n'
|
||
f'<meta property="og:title" content="{title}">\n'
|
||
f'<meta property="og:description" content="{og_desc}">\n'
|
||
f'<meta property="og:url" content="{url}">\n'
|
||
f'<meta property="og:type" content="website">\n'
|
||
f'<meta property="og:image" content="{og_img}">\n'
|
||
f'<meta property="og:image:width" content="1200">\n'
|
||
f'<meta property="og:image:height" content="630">\n'
|
||
f'<meta property="og:locale" content="{SEO_LOCALE[lang]}">\n'
|
||
f'<meta name="twitter:card" content="summary_large_image">\n'
|
||
f'<meta name="twitter:title" content="{title}">\n'
|
||
f'<meta name="twitter:description" content="{og_desc}">\n'
|
||
f'<meta name="twitter:image" content="{og_img}">\n'
|
||
f'<link rel="icon" type="image/x-icon" href="/favicon.ico">\n'
|
||
f'<link rel="shortcut icon" href="/favicon.ico">\n'
|
||
f'<script type="application/ld+json">\n{ld_json}\n</script>'
|
||
)
|
||
|
||
|
||
def _render_index_html(lang: str) -> str:
|
||
lang = lang if lang in SEO else "ru"
|
||
html = _load_index_template()
|
||
# Локализуем язык документа. На корне (/ = ru/x-default) data-prelang оставляем
|
||
# пустым, чтобы уважать сохранённый выбор пользователя и авто-детект языка;
|
||
# на явных языковых URL (/en/, /de/…) prelang жёсткий — страница на своём языке.
|
||
prelang = "" if lang == "ru" else lang
|
||
html = html.replace(
|
||
'<html lang="ru" data-prelang="ru">',
|
||
f'<html lang="{lang}" data-prelang="{prelang}">',
|
||
)
|
||
# Заменяем SEO-блок между маркерами на локализованный
|
||
start = html.find("<!--SEO_HEAD-->")
|
||
end = html.find("<!--/SEO_HEAD-->")
|
||
if start != -1 and end != -1:
|
||
head = _build_seo_head(lang)
|
||
html = html[:start] + "<!--SEO_HEAD-->\n" + head + "\n" + html[end:]
|
||
return html
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def index():
|
||
return _render_index_html("ru")
|
||
|
||
|
||
@app.get("/en/", response_class=HTMLResponse)
|
||
@app.get("/en", response_class=HTMLResponse)
|
||
async def index_en():
|
||
return _render_index_html("en")
|
||
|
||
|
||
@app.get("/de/", response_class=HTMLResponse)
|
||
@app.get("/de", response_class=HTMLResponse)
|
||
async def index_de():
|
||
return _render_index_html("de")
|
||
|
||
|
||
@app.get("/fr/", response_class=HTMLResponse)
|
||
@app.get("/fr", response_class=HTMLResponse)
|
||
async def index_fr():
|
||
return _render_index_html("fr")
|
||
|
||
|
||
@app.get("/it/", response_class=HTMLResponse)
|
||
@app.get("/it", response_class=HTMLResponse)
|
||
async def index_it():
|
||
return _render_index_html("it")
|
||
|
||
|
||
@app.get("/es/", response_class=HTMLResponse)
|
||
@app.get("/es", response_class=HTMLResponse)
|
||
async def index_es():
|
||
return _render_index_html("es")
|
||
|
||
|
||
@app.get("/pt/", response_class=HTMLResponse)
|
||
@app.get("/pt", response_class=HTMLResponse)
|
||
async def index_pt():
|
||
return _render_index_html("pt")
|
||
|
||
|
||
@app.get("/favicon.ico", include_in_schema=False)
|
||
async def favicon():
|
||
return FileResponse("/web_app/static/favicon.ico", media_type="image/x-icon")
|
||
|
||
|
||
@app.get("/robots.txt")
|
||
async def robots():
|
||
return HTMLResponse(
|
||
content=(
|
||
"User-agent: *\n"
|
||
"Allow: /\n"
|
||
"Disallow: /api/\n"
|
||
"Disallow: /static/\n"
|
||
"\n"
|
||
"Sitemap: https://pdf2scan.online/sitemap.xml\n"
|
||
),
|
||
media_type="text/plain",
|
||
)
|
||
|
||
|
||
@app.get("/sitemap.xml")
|
||
async def sitemap():
|
||
today = datetime.date.today().isoformat()
|
||
# Все языковые страницы + перекрёстные hreflang-альтернативы (xhtml:link)
|
||
alternates = "".join(
|
||
f' <xhtml:link rel="alternate" hreflang="{lc}" href="{SEO_BASE}{p}"/>\n'
|
||
for lc, p in SEO_LANG_PATH.items()
|
||
)
|
||
alternates += f' <xhtml:link rel="alternate" hreflang="x-default" href="{SEO_BASE}/"/>\n'
|
||
urls = ""
|
||
for lc, p in SEO_LANG_PATH.items():
|
||
urls += (
|
||
' <url>\n'
|
||
f' <loc>{SEO_BASE}{p}</loc>\n'
|
||
f'{alternates}'
|
||
f' <lastmod>{today}</lastmod>\n'
|
||
' <changefreq>weekly</changefreq>\n'
|
||
f' <priority>{"1.0" if lc == "ru" else "0.9"}</priority>\n'
|
||
' </url>\n'
|
||
)
|
||
# Страница API-документации (для запросов «pdf scan api»)
|
||
urls += (
|
||
' <url>\n'
|
||
f' <loc>{SEO_BASE}/developers</loc>\n'
|
||
f' <lastmod>{today}</lastmod>\n'
|
||
' <changefreq>monthly</changefreq>\n'
|
||
' <priority>0.7</priority>\n'
|
||
' </url>\n'
|
||
)
|
||
return HTMLResponse(
|
||
content=(
|
||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n'
|
||
' xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||
f'{urls}'
|
||
'</urlset>\n'
|
||
),
|
||
media_type="application/xml",
|
||
)
|
||
|
||
|
||
@app.post("/api/auth/token")
|
||
async def create_auth_token():
|
||
"""Создаёт одноразовый токен для авторизации через бота."""
|
||
token = secrets.token_hex(16)
|
||
_db_create_auth_token(token)
|
||
url = f"tg://resolve?domain={BOT_USERNAME}&start=webauth_{token}"
|
||
return {"token": token, "url": url, "expires_in": AUTH_TOKEN_TTL}
|
||
|
||
|
||
@app.get("/api/auth/poll/{token}")
|
||
async def poll_auth_token(token: str):
|
||
"""Проверяет статус авторизации по токену."""
|
||
row = _db_poll_auth_token(token)
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Token not found")
|
||
if time.time() - row["created_at"] > AUTH_TOKEN_TTL:
|
||
return {"status": "expired"}
|
||
if row["status"] == "verified":
|
||
uid = row["user_id"]
|
||
if uid:
|
||
with _verified_users_lock:
|
||
_verified_users[uid] = time.time()
|
||
_db_mark_verified(uid)
|
||
return {
|
||
"status": "verified",
|
||
"user_id": uid,
|
||
"first_name": row["first_name"] or "",
|
||
"last_name": row["last_name"] or "",
|
||
"username": row["username"] or "",
|
||
}
|
||
return {"status": "pending"}
|
||
|
||
|
||
@app.post("/api/check")
|
||
async def check_auth_required(pages: int = Form(0), size_mb: float = Form(0.0)):
|
||
requires_auth = pages > AUTH_REQUIRED_PAGES or size_mb > AUTH_REQUIRED_MB
|
||
return {"requires_auth": requires_auth}
|
||
|
||
|
||
@app.post("/api/auth/verify")
|
||
async def verify_auth(data: dict):
|
||
if not _verify_telegram_auth(data):
|
||
raise HTTPException(status_code=401, detail="Invalid Telegram auth data")
|
||
try:
|
||
uid = int(data.get("id", 0))
|
||
if uid:
|
||
with _verified_users_lock:
|
||
_verified_users[uid] = time.time()
|
||
_db_mark_verified(uid)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return {"ok": True, "user_id": data.get("id")}
|
||
|
||
|
||
def _count_pdf_pages(content: bytes) -> int:
|
||
"""Быстрый подсчёт страниц по байтам PDF без внешних зависимостей."""
|
||
try:
|
||
matches = re.findall(rb'/Type\s*/Page[^s]', content)
|
||
return len(matches)
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def _write_bytes(path: str, data: bytes) -> None:
|
||
"""Синхронная запись файла — вызывается через asyncio.to_thread, чтобы не блокировать event-loop."""
|
||
with open(path, "wb") as f_out:
|
||
f_out.write(data)
|
||
|
||
|
||
def _read_and_count_pdf(path: str) -> int:
|
||
"""Читает PDF с диска и считает страницы (для запуска в отдельном потоке)."""
|
||
try:
|
||
with open(path, "rb") as f_in:
|
||
return _count_pdf_pages(f_in.read())
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def _safe_remove(path: str) -> None:
|
||
try:
|
||
os.remove(path)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
_UPLOAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
||
|
||
|
||
def _chunk_part_path(upload_id: str):
|
||
"""Путь к временному .part-файлу чанковой загрузки (с валидацией upload_id)."""
|
||
if not upload_id or not _UPLOAD_ID_RE.match(upload_id):
|
||
return None
|
||
return f"{UPLOAD_DIR}{upload_id}.part"
|
||
|
||
|
||
def _write_chunk_at(path: str, data: bytes, offset: int) -> None:
|
||
"""Пишет кусок по смещению (идемпотентно при ретраях). Вызывается через to_thread."""
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
fd = os.open(path, os.O_WRONLY | os.O_CREAT, 0o644)
|
||
try:
|
||
os.pwrite(fd, data, offset)
|
||
finally:
|
||
os.close(fd)
|
||
|
||
|
||
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,
|
||
account_id=None):
|
||
"""Общий хвост постановки задания: проверка авторизации, запись в БД, _meta.
|
||
trusted=True (бизнес-API по ключу) — пропускает пейволл.
|
||
account_id — аккаунт запроса (из сессии/TG) для проверки подписки.
|
||
web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id)."""
|
||
# Пейволл: ≤50МБ и ≤100стр — бесплатно; больше — нужна активная подписка,
|
||
# иначе задание держим (url_res_f='awaiting_payment') до разовой оплаты 99₽.
|
||
acc = account_id or _account_id(user_id)
|
||
held = False
|
||
if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES):
|
||
if _db_sub_active(acc):
|
||
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
|
||
try:
|
||
_db_insert_job(
|
||
name_or_f=job_id,
|
||
stitching=stitching,
|
||
title=title,
|
||
numeration=numeration,
|
||
flip_side=flip_side,
|
||
color=bot_color,
|
||
pdfa=pdfa,
|
||
quality=quality,
|
||
original_name=original_name,
|
||
source_size_mb=size_mb,
|
||
ocr=ocr,
|
||
web_owner_id=owner,
|
||
)
|
||
except Exception as exc:
|
||
_safe_remove(file_path)
|
||
raise HTTPException(status_code=500, detail=f"Ошибка записи в БД: {exc}")
|
||
|
||
with _meta_lock:
|
||
_meta[job_id] = {
|
||
"original_name": original_name,
|
||
"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}
|
||
|
||
|
||
def _build_zip(items) -> str:
|
||
"""Собирает ZIP из готовых PDF. items = [(src_path, arcname), ...]. Возвращает путь к временному архиву."""
|
||
import tempfile
|
||
import zipfile
|
||
fd, zip_path = tempfile.mkstemp(suffix=".zip", dir=BOT_TEMP)
|
||
os.close(fd)
|
||
# ZIP_STORED: PDF уже сжаты, повторное сжатие только грузит CPU без выгоды
|
||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf:
|
||
for src_path, arcname in items:
|
||
zf.write(src_path, arcname=arcname)
|
||
return zip_path
|
||
|
||
|
||
@app.post("/api/submit")
|
||
async def submit(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
stitching: str = Form("без сшивки"),
|
||
title: int = Form(0),
|
||
numeration: int = Form(0),
|
||
flip_side: int = Form(0),
|
||
color: str = Form("color"),
|
||
pdfa: int = Form(0),
|
||
quality: str = Form("high"),
|
||
ocr: int = Form(0),
|
||
user_id: int = Form(0),
|
||
):
|
||
original_name = file.filename or "document.pdf"
|
||
src_ext = os.path.splitext(original_name)[1].lower()
|
||
is_doc = src_ext in (".doc", ".docx")
|
||
|
||
# Именование аналогично телеграм-боту: YYYY.MM.DD_HH-MM-SS_имя_файла
|
||
_stem = os.path.splitext(original_name)[0].replace("'", "").replace(":", "")
|
||
job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}"
|
||
file_path = f"{BOT_TEMP}{job_id}.pdf"
|
||
|
||
content = await file.read()
|
||
size_mb = len(content) / (1024 * 1024)
|
||
|
||
if is_doc:
|
||
# Сохраняем оригинальный Word-документ во временный файл
|
||
src_path = f"{BOT_TEMP}{job_id}{src_ext}"
|
||
try:
|
||
await asyncio.to_thread(_write_bytes, src_path, content)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=500, detail=f"Не удалось сохранить файл: {exc}")
|
||
# Конвертируем в PDF через libreoffice
|
||
try:
|
||
await asyncio.to_thread(_convert_doc_to_pdf, src_path, file_path)
|
||
except Exception as exc:
|
||
try:
|
||
os.remove(src_path)
|
||
except Exception:
|
||
pass
|
||
raise HTTPException(status_code=500, detail=f"Не удалось конвертировать DOC/DOCX: {exc}")
|
||
finally:
|
||
try:
|
||
os.remove(src_path)
|
||
except Exception:
|
||
pass
|
||
# Считаем страницы из конвертированного PDF
|
||
pages = await asyncio.to_thread(_read_and_count_pdf, file_path)
|
||
else:
|
||
pages = await asyncio.to_thread(_count_pdf_pages, content)
|
||
try:
|
||
await asyncio.to_thread(_write_bytes, file_path, content)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=500, detail=f"Не удалось сохранить файл: {exc}")
|
||
|
||
return _finalize_submit(
|
||
job_id, file_path, original_name, stitching, title,
|
||
numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id,
|
||
ocr=ocr, account_id=_resolve_account(request, user_id),
|
||
)
|
||
|
||
|
||
@app.post("/api/upload-chunk")
|
||
async def upload_chunk(
|
||
upload_id: str = Form(...),
|
||
offset: int = Form(...),
|
||
chunk: UploadFile = File(...),
|
||
):
|
||
"""Приём одного куска файла (чанковая загрузка в обход потолка туннеля)."""
|
||
part_path = _chunk_part_path(upload_id)
|
||
if not part_path:
|
||
raise HTTPException(status_code=400, detail="Bad upload_id")
|
||
if offset < 0 or offset > MAX_UPLOAD_BYTES:
|
||
raise HTTPException(status_code=400, detail="Bad offset")
|
||
data = await chunk.read()
|
||
if len(data) > MAX_CHUNK_BYTES:
|
||
raise HTTPException(status_code=413, detail="Chunk too large")
|
||
if offset + len(data) > MAX_UPLOAD_BYTES:
|
||
raise HTTPException(status_code=413, detail="Upload too large")
|
||
await asyncio.to_thread(_write_chunk_at, part_path, data, offset)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/submit-chunked")
|
||
async def submit_chunked(
|
||
request: Request,
|
||
upload_id: str = Form(...),
|
||
filename: str = Form("document.pdf"),
|
||
total_size: int = Form(0),
|
||
stitching: str = Form("без сшивки"),
|
||
title: int = Form(0),
|
||
numeration: int = Form(0),
|
||
flip_side: int = Form(0),
|
||
color: str = Form("color"),
|
||
pdfa: int = Form(0),
|
||
quality: str = Form("high"),
|
||
ocr: int = Form(0),
|
||
user_id: int = Form(0),
|
||
):
|
||
"""Финализация чанковой загрузки: собранный .part становится заданием в очереди."""
|
||
part_path = _chunk_part_path(upload_id)
|
||
if not part_path or not os.path.exists(part_path):
|
||
raise HTTPException(status_code=404, detail="Загрузка не найдена")
|
||
actual = os.path.getsize(part_path)
|
||
if total_size and actual != total_size:
|
||
_safe_remove(part_path)
|
||
raise HTTPException(status_code=400, detail=f"Размер не совпал ({actual} != {total_size})")
|
||
|
||
original_name = filename or "document.pdf"
|
||
src_ext = os.path.splitext(original_name)[1].lower()
|
||
is_doc = src_ext in (".doc", ".docx")
|
||
_stem = os.path.splitext(original_name)[0].replace("'", "").replace(":", "")
|
||
job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}"
|
||
file_path = f"{BOT_TEMP}{job_id}.pdf"
|
||
size_mb = actual / (1024 * 1024)
|
||
|
||
try:
|
||
if is_doc:
|
||
src_path = f"{BOT_TEMP}{job_id}{src_ext}"
|
||
await asyncio.to_thread(os.replace, part_path, src_path)
|
||
try:
|
||
await asyncio.to_thread(_convert_doc_to_pdf, src_path, file_path)
|
||
finally:
|
||
_safe_remove(src_path)
|
||
pages = await asyncio.to_thread(_read_and_count_pdf, file_path)
|
||
else:
|
||
await asyncio.to_thread(os.replace, part_path, file_path)
|
||
pages = await asyncio.to_thread(_read_and_count_pdf, file_path)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
_safe_remove(part_path)
|
||
_safe_remove(file_path)
|
||
raise HTTPException(status_code=500, detail=f"Не удалось обработать файл: {exc}")
|
||
|
||
return _finalize_submit(
|
||
job_id, file_path, original_name, stitching, title,
|
||
numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id,
|
||
ocr=ocr, account_id=_resolve_account(request, user_id),
|
||
)
|
||
|
||
|
||
@app.get("/api/status/{job_id}")
|
||
async def status(job_id: str):
|
||
url_res_f = _db_get_url_res_f(job_id)
|
||
|
||
if url_res_f is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
|
||
if url_res_f in ("-", "", None):
|
||
pos = _db_queue_position(job_id)
|
||
if pos <= 1:
|
||
# передний в очереди — бот его сейчас и обрабатывает (он не пишет 'processing' в БД)
|
||
return {"status": "processing", "progress": 50, "queue_pos": 0, "error": None}
|
||
return {"status": "queued", "progress": 0, "queue_pos": pos, "error": None}
|
||
|
||
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": "Ошибка обработки"}
|
||
|
||
if url_res_f == "cancelled":
|
||
return {"status": "error", "progress": 0, "queue_pos": 0, "error": "Обработка отменена"}
|
||
|
||
if url_res_f == "expired":
|
||
return {"status": "error", "progress": 0, "queue_pos": 0, "error": "Ссылка истекла. Отправьте файл заново."}
|
||
|
||
if url_res_f == "web_local":
|
||
# Считаем оставшееся время
|
||
with _meta_lock:
|
||
meta = _meta.get(job_id, {})
|
||
created_at = meta.get("created_at", time.time())
|
||
expires_in = max(0, int(RESULT_TTL - (time.time() - created_at)))
|
||
return {"status": "done", "progress": 100, "queue_pos": 0, "error": None, "expires_in": expires_in}
|
||
|
||
# Любой другой непустой статус — тоже готово
|
||
return {"status": "done", "progress": 100, "queue_pos": 0, "error": None}
|
||
|
||
|
||
def _make_download_response(job_id: str) -> FileResponse:
|
||
"""Готовит FileResponse с результатом задания (общий код для веба и бизнес-API)."""
|
||
url_res_f = _db_get_url_res_f(job_id)
|
||
if url_res_f != "web_local":
|
||
raise HTTPException(status_code=409, detail="Not ready")
|
||
|
||
with _meta_lock:
|
||
meta = _meta.get(job_id, {})
|
||
original_name = meta.get("original_name")
|
||
stitching = meta.get("stitching")
|
||
if not original_name or not stitching:
|
||
db_name, db_st = _db_get_job_meta(job_id) # фоллбэк на БД (переживает рестарт)
|
||
original_name = original_name or db_name or "document.pdf"
|
||
stitching = stitching or db_st or "без сшивки"
|
||
base = os.path.splitext(original_name)[0]
|
||
download_name = f"{base} скан.pdf"
|
||
result_path = f"{BOT_TEMP}{job_id} скан {stitching}.pdf"
|
||
|
||
if not os.path.exists(result_path):
|
||
_db_set_url_res_f(job_id, "expired")
|
||
raise HTTPException(status_code=404, detail="Файл не найден или уже удалён")
|
||
|
||
return FileResponse(
|
||
result_path,
|
||
media_type="application/pdf",
|
||
filename=download_name,
|
||
)
|
||
|
||
|
||
@app.get("/api/download/{job_id}")
|
||
async def download(job_id: str):
|
||
return _make_download_response(job_id)
|
||
|
||
|
||
def _collect_zip_items(job_ids):
|
||
"""[(result_path, arcname)] для готовых заданий (имена из _meta или БД)."""
|
||
items = []
|
||
used = set()
|
||
for jid in job_ids:
|
||
if not isinstance(jid, str):
|
||
continue
|
||
if _db_get_url_res_f(jid) != "web_local":
|
||
continue
|
||
with _meta_lock:
|
||
meta = _meta.get(jid, {})
|
||
original_name = meta.get("original_name")
|
||
stitching = meta.get("stitching")
|
||
if not original_name or not stitching:
|
||
db_name, db_st = _db_get_job_meta(jid)
|
||
original_name = original_name or db_name or "document.pdf"
|
||
stitching = stitching or db_st or "без сшивки"
|
||
result_path = f"{BOT_TEMP}{jid} скан {stitching}.pdf"
|
||
if not os.path.exists(result_path):
|
||
continue
|
||
base = os.path.splitext(os.path.basename(original_name))[0]
|
||
arcname = f"{base} скан.pdf"
|
||
n = 1
|
||
while arcname in used:
|
||
n += 1
|
||
arcname = f"{base} скан ({n}).pdf"
|
||
used.add(arcname)
|
||
items.append((result_path, arcname))
|
||
return items
|
||
|
||
|
||
def _zip_build_worker(zip_id, items):
|
||
"""Фоновая сборка ZIP (большие архивы не успевают за таймаут заголовков прокси)."""
|
||
try:
|
||
path = _build_zip(items)
|
||
with _zip_jobs_lock:
|
||
j = _zip_jobs.get(zip_id)
|
||
if j is not None:
|
||
j["status"] = "ready"
|
||
j["path"] = path
|
||
except Exception as exc:
|
||
with _zip_jobs_lock:
|
||
j = _zip_jobs.get(zip_id)
|
||
if j is not None:
|
||
j["status"] = "error"
|
||
j["error"] = str(exc)
|
||
|
||
|
||
@app.post("/api/zip/prepare")
|
||
async def zip_prepare(payload: dict):
|
||
"""Старт фоновой сборки ZIP. Возвращает zip_id; статус — /api/zip/status."""
|
||
job_ids = payload.get("job_ids") if isinstance(payload, dict) else None
|
||
if not isinstance(job_ids, list) or not job_ids:
|
||
raise HTTPException(status_code=400, detail="job_ids required")
|
||
if len(job_ids) > 20:
|
||
raise HTTPException(status_code=400, detail="Слишком много файлов (максимум 20)")
|
||
items = _collect_zip_items(job_ids)
|
||
if not items:
|
||
raise HTTPException(status_code=409, detail="Нет готовых файлов для архива")
|
||
zip_id = secrets.token_hex(8)
|
||
download_name = f"pdf2scan_{datetime.datetime.now().strftime('%Y-%m-%d')}.zip"
|
||
with _zip_jobs_lock:
|
||
_zip_jobs[zip_id] = {"status": "building", "created_at": time.time(), "name": download_name}
|
||
threading.Thread(target=_zip_build_worker, args=(zip_id, items), daemon=True).start()
|
||
return {"zip_id": zip_id, "name": download_name}
|
||
|
||
|
||
@app.get("/api/zip/status/{zip_id}")
|
||
async def zip_status(zip_id: str):
|
||
with _zip_jobs_lock:
|
||
j = _zip_jobs.get(zip_id)
|
||
if not j:
|
||
raise HTTPException(status_code=404, detail="not found")
|
||
return {"status": j.get("status"), "name": j.get("name"), "error": j.get("error")}
|
||
|
||
|
||
@app.get("/api/zip/file/{zip_id}")
|
||
async def zip_file(zip_id: str):
|
||
with _zip_jobs_lock:
|
||
j = _zip_jobs.get(zip_id)
|
||
if not j or j.get("status") != "ready" or not j.get("path") or not os.path.exists(j["path"]):
|
||
raise HTTPException(status_code=404, detail="Архив не готов")
|
||
path = j["path"]
|
||
name = j.get("name", "pdf2scan.zip")
|
||
|
||
def _cleanup():
|
||
_safe_remove(path)
|
||
with _zip_jobs_lock:
|
||
_zip_jobs.pop(zip_id, None)
|
||
|
||
return FileResponse(
|
||
path,
|
||
media_type="application/zip",
|
||
filename=name,
|
||
background=BackgroundTask(_cleanup),
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Бизнес-API (доступ по ключу) — /api/v1/*
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
API_RATE_PER_MIN = 60 # мягкий лимит запросов в минуту на ключ
|
||
_api_rate: dict[str, list] = {}
|
||
_api_rate_lock = threading.Lock()
|
||
|
||
|
||
def _db_create_api_key(owner_id: int, name: str, plan: str = "free") -> str:
|
||
key = "sk_" + secrets.token_hex(24)
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"INSERT INTO WEB_API_KEYS (api_key, owner_id, name, plan, active, req_count, created_at) "
|
||
"VALUES (?, ?, ?, ?, 1, 0, ?)",
|
||
(key, int(owner_id or 0), (name or "key")[:64], plan, time.time()),
|
||
)
|
||
con.commit()
|
||
return key
|
||
|
||
|
||
def _db_get_api_key(key: str):
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute(
|
||
"SELECT api_key, owner_id, name, plan, active, req_count "
|
||
"FROM WEB_API_KEYS WHERE api_key = ?",
|
||
(key,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return None
|
||
return {
|
||
"api_key": row[0], "owner_id": row[1], "name": row[2],
|
||
"plan": row[3], "active": row[4], "req_count": row[5],
|
||
}
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _db_touch_api_key(key: str):
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"UPDATE WEB_API_KEYS SET req_count = req_count + 1, last_req = ? WHERE api_key = ?",
|
||
(time.time(), key),
|
||
)
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _extract_api_key(request: Request) -> str | None:
|
||
auth = request.headers.get("authorization") or ""
|
||
if auth.lower().startswith("bearer "):
|
||
return auth[7:].strip()
|
||
return request.headers.get("x-api-key")
|
||
|
||
|
||
def _require_api_key(request: Request) -> dict:
|
||
key = _extract_api_key(request)
|
||
if not key:
|
||
raise HTTPException(
|
||
status_code=401,
|
||
detail="Missing API key. Use 'Authorization: Bearer <key>' or 'X-API-Key' header.",
|
||
)
|
||
row = _db_get_api_key(key)
|
||
if not row or not row.get("active"):
|
||
raise HTTPException(status_code=401, detail="Invalid or inactive API key.")
|
||
now = time.time()
|
||
with _api_rate_lock:
|
||
bucket = [t for t in _api_rate.get(key, []) if now - t < 60]
|
||
if len(bucket) >= API_RATE_PER_MIN:
|
||
raise HTTPException(status_code=429, detail=f"Rate limit exceeded ({API_RATE_PER_MIN}/min).")
|
||
bucket.append(now)
|
||
_api_rate[key] = bucket
|
||
_db_touch_api_key(key)
|
||
return row
|
||
|
||
|
||
@app.post("/api/v1/keys")
|
||
async def api_create_key(token: str = Form(...), name: str = Form("api")):
|
||
"""Самовыдача ключа: нужен подтверждённый auth-токen (после входа через Telegram-бота)."""
|
||
row = _db_poll_auth_token(token)
|
||
if not row or row.get("status") != "verified" or not row.get("user_id"):
|
||
raise HTTPException(status_code=401, detail="Требуется вход через Telegram (verified token).")
|
||
key = _db_create_api_key(row["user_id"], name)
|
||
return {"api_key": key, "plan": "free", "rate_limit_per_min": API_RATE_PER_MIN,
|
||
"note": "Сохраните ключ — он показывается один раз."}
|
||
|
||
|
||
@app.post("/api/v1/scan")
|
||
async def api_scan(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
stitching: str = Form("без сшивки"),
|
||
title: int = Form(0),
|
||
numeration: int = Form(0),
|
||
flip_side: int = Form(0),
|
||
color: str = Form("color"),
|
||
pdfa: int = Form(0),
|
||
quality: str = Form("high"),
|
||
ocr: int = Form(0),
|
||
):
|
||
"""Постановка файла в обработку по API-ключу. Возвращает job_id."""
|
||
key_row = _require_api_key(request)
|
||
|
||
original_name = file.filename or "document.pdf"
|
||
src_ext = os.path.splitext(original_name)[1].lower()
|
||
is_doc = src_ext in (".doc", ".docx")
|
||
_stem = os.path.splitext(original_name)[0].replace("'", "").replace(":", "")
|
||
job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}"
|
||
file_path = f"{BOT_TEMP}{job_id}.pdf"
|
||
|
||
content = await file.read()
|
||
size_mb = len(content) / (1024 * 1024)
|
||
if size_mb > MAX_UPLOAD_BYTES / (1024 * 1024):
|
||
raise HTTPException(status_code=413, detail="File too large")
|
||
|
||
if is_doc:
|
||
src_path = f"{BOT_TEMP}{job_id}{src_ext}"
|
||
await asyncio.to_thread(_write_bytes, src_path, content)
|
||
try:
|
||
await asyncio.to_thread(_convert_doc_to_pdf, src_path, file_path)
|
||
except Exception as exc:
|
||
_safe_remove(src_path)
|
||
raise HTTPException(status_code=500, detail=f"DOC/DOCX convert failed: {exc}")
|
||
finally:
|
||
_safe_remove(src_path)
|
||
pages = await asyncio.to_thread(_read_and_count_pdf, file_path)
|
||
else:
|
||
pages = await asyncio.to_thread(_count_pdf_pages, content)
|
||
await asyncio.to_thread(_write_bytes, file_path, content)
|
||
|
||
result = _finalize_submit(
|
||
job_id, file_path, original_name, stitching, title,
|
||
numeration, flip_side, color, pdfa, quality, size_mb, pages,
|
||
user_id=WEB_USER_ID, trusted=True, ocr=ocr,
|
||
web_owner_id=key_row.get("owner_id") or 0,
|
||
)
|
||
return {"job_id": result["job_id"], "pages": pages,
|
||
"status_url": f"/api/v1/scan/{result['job_id']}",
|
||
"download_url": f"/api/v1/scan/{result['job_id']}/download"}
|
||
|
||
|
||
@app.get("/api/v1/scan/{job_id}")
|
||
async def api_scan_status(request: Request, job_id: str):
|
||
"""Статус задания по API-ключу: queued / processing / done / error."""
|
||
_require_api_key(request)
|
||
url_res_f = _db_get_url_res_f(job_id)
|
||
if url_res_f is None:
|
||
raise HTTPException(status_code=404, detail="Job not found")
|
||
if url_res_f in ("-", "", None):
|
||
pos = _db_queue_position(job_id)
|
||
if pos <= 1:
|
||
return {"status": "processing", "queue_pos": 0}
|
||
return {"status": "queued", "queue_pos": pos}
|
||
if url_res_f in ("processing", "uploading"):
|
||
return {"status": "processing", "queue_pos": 0}
|
||
if url_res_f in ("ошибка", "error"):
|
||
return {"status": "error", "error": "processing failed"}
|
||
if url_res_f == "cancelled":
|
||
return {"status": "error", "error": "cancelled"}
|
||
if url_res_f == "expired":
|
||
return {"status": "error", "error": "expired"}
|
||
return {"status": "done", "download_url": f"/api/v1/scan/{job_id}/download"}
|
||
|
||
|
||
@app.get("/api/v1/scan/{job_id}/download")
|
||
async def api_scan_download(request: Request, job_id: str):
|
||
"""Скачивание результата по API-ключу."""
|
||
_require_api_key(request)
|
||
return _make_download_response(job_id)
|
||
|
||
|
||
# ── Личный кабинет: история «Мои документы» ───────────────────────────────────
|
||
def _db_history(owner_id: int, limit: int = 40):
|
||
"""Последние веб-задания владельца (для личного кабинета)."""
|
||
rows = []
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.cursor()
|
||
cur.execute("PRAGMA table_info(FILES)")
|
||
cols = {r[1] for r in cur.fetchall()}
|
||
if "web_owner_id" not in cols:
|
||
return rows
|
||
oname = "original_name" if "original_name" in cols else "name_or_f"
|
||
cur.execute(
|
||
f"SELECT name_or_f, {oname}, date, time, url_res_f, stitching "
|
||
"FROM FILES WHERE web_owner_id = ? ORDER BY rowid DESC LIMIT ?",
|
||
(int(owner_id), limit),
|
||
)
|
||
for r in cur.fetchall():
|
||
job_id, name, date, tm, url_res_f, stitching = r
|
||
if url_res_f == "web_local":
|
||
result_path = f"{BOT_TEMP}{job_id} скан {stitching}.pdf"
|
||
st = "available" if os.path.exists(result_path) else "expired"
|
||
elif url_res_f in ("-", "", None):
|
||
st = "processing"
|
||
elif url_res_f in ("ошибка", "error"):
|
||
st = "error"
|
||
else:
|
||
st = "expired"
|
||
rows.append({
|
||
"job_id": job_id, "name": name or "document.pdf",
|
||
"date": date, "time": tm, "status": st,
|
||
})
|
||
except Exception as exc:
|
||
print(f"history error: {exc}")
|
||
return rows
|
||
|
||
|
||
@app.get("/api/history/{user_id}")
|
||
async def history(user_id: int):
|
||
"""История документов пользователя. Только для подтверждённых (вошедших) юзеров."""
|
||
if not user_id or not _db_is_verified(user_id):
|
||
raise HTTPException(status_code=403, detail="Требуется вход через Telegram.")
|
||
return {"items": _db_history(user_id)}
|
||
|
||
|
||
# ── Загрузка ZIP: распаковка и постановка каждого PDF/DOCX в очередь ───────────
|
||
def _unpack_zip_and_enqueue(part_path: str, settings: dict, user_id: int) -> list[dict]:
|
||
"""Распаковывает ZIP, ставит в очередь каждый вложенный PDF/DOC(X). Возвращает [{job_id,name}]."""
|
||
import zipfile
|
||
created = []
|
||
verified = bool(user_id) and (_db_is_verified(user_id) or (
|
||
_verified_users.get(user_id, 0) and time.time() - _verified_users[user_id] <= 86400))
|
||
try:
|
||
with zipfile.ZipFile(part_path) as zf:
|
||
names = [n for n in zf.namelist()
|
||
if not n.endswith("/") and os.path.splitext(n)[1].lower() in (".pdf", ".doc", ".docx")]
|
||
names = names[:20] # не больше 20 файлов за раз
|
||
for inner in names:
|
||
ext = os.path.splitext(inner)[1].lower()
|
||
base = os.path.basename(inner) or f"file{ext}"
|
||
_stem = os.path.splitext(base)[0].replace("'", "").replace(":", "")
|
||
job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}_{secrets.token_hex(2)}"
|
||
file_path = f"{BOT_TEMP}{job_id}.pdf"
|
||
data = zf.read(inner)
|
||
size_mb = len(data) / (1024 * 1024)
|
||
try:
|
||
if ext in (".doc", ".docx"):
|
||
src_path = f"{BOT_TEMP}{job_id}{ext}"
|
||
_write_bytes(src_path, data)
|
||
try:
|
||
_convert_doc_to_pdf(src_path, file_path)
|
||
finally:
|
||
_safe_remove(src_path)
|
||
pages = _read_and_count_pdf(file_path)
|
||
else:
|
||
_write_bytes(file_path, data)
|
||
pages = _count_pdf_pages(data)
|
||
# авторизация по порогу — как в обычном submit (кроме verified)
|
||
if not verified and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES):
|
||
_safe_remove(file_path)
|
||
continue
|
||
_finalize_submit(
|
||
job_id, file_path, base, settings.get("stitching", "без сшивки"),
|
||
settings.get("title", 0), settings.get("numeration", 0),
|
||
settings.get("flip_side", 0), settings.get("color", "color"),
|
||
settings.get("pdfa", 0), settings.get("quality", "high"),
|
||
size_mb, pages, user_id, trusted=True, ocr=settings.get("ocr", 0),
|
||
web_owner_id=user_id,
|
||
)
|
||
created.append({"job_id": job_id, "name": base})
|
||
except Exception as exc:
|
||
print(f"zip entry '{inner}' failed: {exc}")
|
||
_safe_remove(file_path)
|
||
finally:
|
||
_safe_remove(part_path)
|
||
return created
|
||
|
||
|
||
@app.post("/api/submit-zip-chunked")
|
||
async def submit_zip_chunked(
|
||
upload_id: str = Form(...),
|
||
total_size: int = Form(0),
|
||
stitching: str = Form("без сшивки"),
|
||
title: int = Form(0),
|
||
numeration: int = Form(0),
|
||
flip_side: int = Form(0),
|
||
color: str = Form("color"),
|
||
pdfa: int = Form(0),
|
||
quality: str = Form("high"),
|
||
ocr: int = Form(0),
|
||
user_id: int = Form(0),
|
||
):
|
||
"""Финализация загруженного ZIP: распаковать и поставить вложенные файлы в очередь."""
|
||
part_path = _chunk_part_path(upload_id)
|
||
if not part_path or not os.path.exists(part_path):
|
||
raise HTTPException(status_code=404, detail="Загрузка не найдена")
|
||
actual = os.path.getsize(part_path)
|
||
if total_size and actual != total_size:
|
||
_safe_remove(part_path)
|
||
raise HTTPException(status_code=400, detail=f"Размер не совпал ({actual} != {total_size})")
|
||
settings = {
|
||
"stitching": stitching, "title": title, "numeration": numeration,
|
||
"flip_side": flip_side, "color": color, "pdfa": pdfa, "quality": quality, "ocr": ocr,
|
||
}
|
||
created = await asyncio.to_thread(_unpack_zip_and_enqueue, part_path, settings, user_id)
|
||
if not created:
|
||
raise HTTPException(status_code=400, detail="В архиве нет подходящих файлов (PDF/DOC/DOCX) или превышен лимит/порог авторизации.")
|
||
return {"jobs": created}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Платежи ЮKassa — /api/pay/*, вебхук
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
import base64 as _b64
|
||
import json as _pjson
|
||
import urllib.request as _urlreq
|
||
import urllib.error as _urlerr
|
||
|
||
|
||
def _yk_auth_header() -> str:
|
||
raw = f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()
|
||
return "Basic " + _b64.b64encode(raw).decode()
|
||
|
||
|
||
def _yk_request(method: str, path: str, body: dict | None = None, idempotence_key: str | None = None) -> dict:
|
||
"""Синхронный вызов API ЮKassa (stdlib urllib). Вызывать через asyncio.to_thread."""
|
||
data = _pjson.dumps(body).encode() if body is not None else None
|
||
req = _urlreq.Request(f"{YOOKASSA_API}{path}", data=data, method=method)
|
||
req.add_header("Authorization", _yk_auth_header())
|
||
req.add_header("Content-Type", "application/json")
|
||
if idempotence_key:
|
||
req.add_header("Idempotence-Key", idempotence_key)
|
||
try:
|
||
with _urlreq.urlopen(req, timeout=25) as resp:
|
||
return _pjson.loads(resp.read().decode())
|
||
except _urlerr.HTTPError as e:
|
||
raise HTTPException(status_code=502, detail=f"ЮKassa {e.code}: {e.read().decode(errors='ignore')[:300]}")
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"ЮKassa недоступна: {e}")
|
||
|
||
|
||
def _db_record_payment(payment_id, amount, currency, status, purpose, api_key, email):
|
||
"""Пишет/обновляет платёж, сохраняя paid_at и email при повторной записи из вебхука."""
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"INSERT INTO WEB_PAYMENTS (payment_id, amount, currency, status, purpose, api_key, email, created_at, paid_at) "
|
||
"VALUES (?,?,?,?,?,?,?,?,0) "
|
||
"ON CONFLICT(payment_id) DO UPDATE SET status=excluded.status, "
|
||
"email=COALESCE(NULLIF(excluded.email,''), WEB_PAYMENTS.email)",
|
||
(payment_id, amount, currency, status, purpose, api_key, email, time.time()),
|
||
)
|
||
con.commit()
|
||
except Exception as e:
|
||
print(f"record payment error: {e}")
|
||
|
||
|
||
def _account_id(user_id) -> str | None:
|
||
"""Внутренний id аккаунта из Telegram user_id."""
|
||
try:
|
||
uid = int(user_id or 0)
|
||
except (TypeError, ValueError):
|
||
uid = 0
|
||
return f"tg:{uid}" if uid else None
|
||
|
||
|
||
def _resolve_account(request, user_id=0) -> str | None:
|
||
"""Аккаунт запроса: сначала cookie-сессия (Yandex/VK), иначе tg:<user_id>."""
|
||
try:
|
||
sess = _db_get_session(request.cookies.get(SESSION_COOKIE)) if request else None
|
||
except Exception:
|
||
sess = None
|
||
if sess and sess.get("account_id"):
|
||
return sess["account_id"]
|
||
return _account_id(user_id)
|
||
|
||
|
||
def _db_sub_until(account_id: str | None) -> float:
|
||
"""Время окончания подписки (unix) или 0."""
|
||
if not account_id:
|
||
return 0.0
|
||
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 float(row[0] or 0) if row else 0.0
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
def _db_sub_active(account_id: str | None) -> bool:
|
||
return _db_sub_until(account_id) > time.time()
|
||
|
||
|
||
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_set_autorenew(account_id, pm_id, email):
|
||
"""Сохранить способ оплаты ЮKassa и включить автопродление для аккаунта."""
|
||
if not account_id or not pm_id:
|
||
return
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"UPDATE WEB_ACCOUNTS SET autorenew=1, pm_id=?, "
|
||
"pm_email=COALESCE(NULLIF(?,''), pm_email), renew_fail=0 WHERE account_id=?",
|
||
(pm_id, email or "", account_id))
|
||
con.commit()
|
||
except Exception as e:
|
||
print(f"set autorenew error: {e}")
|
||
|
||
|
||
def _db_get_autorenew(account_id) -> dict:
|
||
if not account_id:
|
||
return {"autorenew": False, "has_method": False}
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.row_factory = sqlite3.Row
|
||
row = con.execute("SELECT autorenew, pm_id FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,)).fetchone()
|
||
if not row:
|
||
return {"autorenew": False, "has_method": False}
|
||
return {"autorenew": bool(row["autorenew"]), "has_method": bool(row["pm_id"])}
|
||
except Exception:
|
||
return {"autorenew": False, "has_method": False}
|
||
|
||
|
||
def _db_set_autorenew_flag(account_id, enable: bool) -> dict:
|
||
"""Вкл/выкл автопродление. Включить можно только при сохранённом способе оплаты."""
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.row_factory = sqlite3.Row
|
||
row = con.execute("SELECT autorenew, pm_id FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,)).fetchone()
|
||
if not row:
|
||
return {"autorenew": False, "has_method": False}
|
||
has_pm = bool(row["pm_id"])
|
||
if enable and not has_pm:
|
||
return {"autorenew": bool(row["autorenew"]), "has_method": False}
|
||
con.execute("UPDATE WEB_ACCOUNTS SET autorenew=? WHERE account_id=?",
|
||
(1 if enable else 0, account_id))
|
||
con.commit()
|
||
return {"autorenew": bool(enable), "has_method": has_pm}
|
||
except Exception as e:
|
||
print(f"toggle autorenew error: {e}")
|
||
return {"autorenew": False, "has_method": False}
|
||
|
||
|
||
def _tg_send(chat_id, text):
|
||
"""Отправка сообщения в Telegram напрямую через Bot API (для уведомлений из веба)."""
|
||
if not BOT_TOKEN or not chat_id:
|
||
return
|
||
try:
|
||
data = _urlparse.urlencode({"chat_id": chat_id, "text": text,
|
||
"disable_web_page_preview": "true"}).encode()
|
||
req = _urlreq.Request(f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
|
||
data=data, method="POST")
|
||
with _urlreq.urlopen(req, timeout=15) as resp:
|
||
resp.read()
|
||
except Exception as e:
|
||
print(f"tg notify error: {e}")
|
||
|
||
|
||
def _notify_owner_payment(payment_id):
|
||
"""Уведомляет владельца об УСПЕШНОМ платеже. Дедуп через WEB_PAYMENTS.notified (шлём один раз)."""
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.row_factory = sqlite3.Row
|
||
cur = con.execute("UPDATE WEB_PAYMENTS SET notified=1 WHERE payment_id=? AND COALESCE(notified,0)=0",
|
||
(payment_id,))
|
||
con.commit()
|
||
if cur.rowcount != 1:
|
||
return # уже уведомляли
|
||
row = con.execute("SELECT amount, purpose, email FROM WEB_PAYMENTS WHERE payment_id=?",
|
||
(payment_id,)).fetchone()
|
||
except Exception:
|
||
return
|
||
if not row:
|
||
return
|
||
label = {"donate": "Донат", "api_pro": "API-тариф", "file": "Оплата файла",
|
||
"sub": "Подписка"}.get(row["purpose"], row["purpose"] or "Платёж")
|
||
text = f"💰 Оплата получена\n{label}: {int(round(row['amount'] or 0))} ₽"
|
||
if row["email"]:
|
||
text += f"\n📧 {row['email']}"
|
||
_tg_send(PAYMENT_NOTIFY_CHAT_ID, text)
|
||
|
||
|
||
def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_method=None):
|
||
"""extra: metadata платежа (job_id для file, account_id для sub).
|
||
payment_method: объект payment_method из ЮKassa (для сохранения при автопродлении)."""
|
||
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=?",
|
||
(time.time(), payment_id))
|
||
if purpose == "api_pro" and api_key:
|
||
cur = con.execute("SELECT paid_until FROM WEB_API_KEYS WHERE api_key=?", (api_key,))
|
||
row = cur.fetchone()
|
||
base = max(time.time(), (row[0] or 0)) if row else time.time()
|
||
until = base + API_PRO_DAYS * 86400
|
||
con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?",
|
||
(until, api_key))
|
||
con.commit()
|
||
# Подписка → продлеваем аккаунт
|
||
if purpose == "sub":
|
||
acc = extra.get("account_id")
|
||
if acc:
|
||
_db_activate_sub(acc, SUB_DAYS)
|
||
# Автопродление: пользователь согласился и способ оплаты сохранён
|
||
if str(extra.get("autorenew")) in ("1", "true", "True") and \
|
||
payment_method and payment_method.get("saved") and payment_method.get("id"):
|
||
_db_set_autorenew(acc, payment_method.get("id"), extra.get("email") or "")
|
||
# И file, и sub: если платёж привязан к заданию — снимаем его с холда
|
||
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, "-")
|
||
# Уведомление владельцу в Telegram (в отдельном потоке — не блокируем event loop)
|
||
threading.Thread(target=_notify_owner_payment, args=(payment_id,), daemon=True).start()
|
||
except Exception as e:
|
||
print(f"mark paid error: {e}")
|
||
|
||
|
||
@app.post("/api/pay/create")
|
||
async def pay_create(payload: dict, request: Request):
|
||
"""Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?, job_id?, user_id?} → confirmation_url."""
|
||
if not YOOKASSA_SHOP_ID or not YOOKASSA_SECRET_KEY:
|
||
raise HTTPException(status_code=503, detail="Приём платежей не настроен.")
|
||
if not isinstance(payload, dict):
|
||
raise HTTPException(status_code=400, detail="Bad payload")
|
||
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 = _resolve_account(request, payload.get("user_id"))
|
||
autorenew = bool(payload.get("autorenew")) and purpose == "sub"
|
||
|
||
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)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="Некорректная сумма")
|
||
if amount < PAY_MIN_RUB or amount > PAY_MAX_RUB:
|
||
raise HTTPException(status_code=400, detail=f"Сумма {PAY_MIN_RUB:.0f}–{PAY_MAX_RUB:.0f} ₽")
|
||
description = "Поддержка проекта pdf2scan"
|
||
|
||
value = f"{amount:.2f}"
|
||
body = {
|
||
"amount": {"value": value, "currency": "RUB"},
|
||
"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,
|
||
"job_id": job_id or "", "account_id": account_id or "",
|
||
"autorenew": "1" if autorenew else ""},
|
||
}
|
||
if autorenew:
|
||
body["save_payment_method"] = True # сохранить способ оплаты для авто-списаний
|
||
if email: # чек 54-ФЗ (нужна подключённая онлайн-касса в ЮKassa)
|
||
body["receipt"] = {
|
||
"customer": {"email": email},
|
||
"items": [{
|
||
"description": description[:128], "quantity": "1.00",
|
||
"amount": {"value": value, "currency": "RUB"},
|
||
"vat_code": 1, "payment_mode": "full_payment", "payment_subject": "service",
|
||
}],
|
||
}
|
||
result = await asyncio.to_thread(_yk_request, "POST", "/payments", body, secrets.token_hex(16))
|
||
pid = result.get("id")
|
||
conf = (result.get("confirmation") or {}).get("confirmation_url")
|
||
_db_record_payment(pid, amount, "RUB", result.get("status", "pending"), purpose, api_key, email)
|
||
return {"payment_id": pid, "confirmation_url": conf, "status": result.get("status")}
|
||
|
||
|
||
@app.post("/api/yookassa/webhook")
|
||
async def yookassa_webhook(payload: dict):
|
||
"""Вебхук ЮKassa. Тело НЕ доверяем — перепроверяем платёж через API."""
|
||
obj = payload.get("object") if isinstance(payload, dict) else None
|
||
pid = (obj or {}).get("id")
|
||
if not pid:
|
||
return {"ok": True}
|
||
try:
|
||
real = await asyncio.to_thread(_yk_request, "GET", f"/payments/{pid}", None, None)
|
||
except Exception:
|
||
return {"ok": True}
|
||
status = real.get("status")
|
||
meta = real.get("metadata") or {}
|
||
purpose = meta.get("purpose") or "donate"
|
||
api_key = meta.get("api_key") or None
|
||
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, extra=meta, payment_method=real.get("payment_method"))
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/pay/status/{payment_id}")
|
||
async def pay_status(payment_id: str):
|
||
"""Статус платежа. Если в БД ещё pending — активно перепроверяем через API ЮKassa
|
||
и активируем (фоллбэк, когда вебхук не настроен/задержался)."""
|
||
db_status, purpose, api_key = None, "donate", None
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.execute("SELECT status, purpose, api_key FROM WEB_PAYMENTS WHERE payment_id=?", (payment_id,))
|
||
row = cur.fetchone()
|
||
if row:
|
||
db_status, purpose, api_key = row[0], row[1], row[2]
|
||
except Exception:
|
||
pass
|
||
if db_status == "succeeded":
|
||
return {"status": "succeeded", "purpose": purpose}
|
||
if not db_status:
|
||
return {"status": "unknown"}
|
||
try:
|
||
real = await asyncio.to_thread(_yk_request, "GET", f"/payments/{payment_id}", None, None)
|
||
status = real.get("status")
|
||
if status and status != db_status:
|
||
meta = real.get("metadata") or {}
|
||
amount = float((real.get("amount") or {}).get("value") or 0)
|
||
rp = meta.get("purpose") or purpose
|
||
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, extra=meta, payment_method=real.get("payment_method"))
|
||
return {"status": status or db_status, "purpose": purpose}
|
||
except Exception:
|
||
return {"status": db_status, "purpose": purpose}
|
||
|
||
|
||
@app.post("/api/paywall/check")
|
||
async def paywall_check(payload: dict, request: Request):
|
||
"""Предпроверка пейволла БЕЗ загрузки файла. body: {size_mb?, pages?, user_id?}.
|
||
Возвращает решение сервера, чтобы фронт показал пейволл сразу."""
|
||
if not isinstance(payload, dict):
|
||
payload = {}
|
||
try:
|
||
size_mb = float(payload.get("size_mb") or 0)
|
||
except (TypeError, ValueError):
|
||
size_mb = 0.0
|
||
try:
|
||
pages = int(payload.get("pages") or 0)
|
||
except (TypeError, ValueError):
|
||
pages = 0
|
||
account = _resolve_account(request, payload.get("user_id"))
|
||
sub_active = _db_sub_active(account)
|
||
over = size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES
|
||
requires_payment = bool(PAYWALL_ENABLED and over and not sub_active)
|
||
return {
|
||
"paywall_enabled": bool(PAYWALL_ENABLED),
|
||
"sub_active": sub_active,
|
||
"requires_payment": requires_payment,
|
||
"file_price": FILE_PRICE_RUB,
|
||
"sub_price": SUB_PRICE_RUB,
|
||
}
|
||
|
||
|
||
# ── Авто-продление подписки (рекуррентные списания ЮKassa) ─────────────────────
|
||
def _bump_renew_fail(account_id):
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute("UPDATE WEB_ACCOUNTS SET renew_fail=renew_fail+1 WHERE account_id=?", (account_id,))
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _charge_renewal(acc_row: dict):
|
||
"""Рекуррентное списание за подписку по сохранённому способу оплаты (payment_method_id)."""
|
||
account_id = acc_row["account_id"]
|
||
# отметим попытку сразу, чтобы не долбить при ошибке (guard раз в 12ч)
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute("UPDATE WEB_ACCOUNTS SET last_renew=? WHERE account_id=?", (time.time(), account_id))
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
value = f"{SUB_PRICE_RUB:.2f}"
|
||
email = acc_row.get("pm_email") or ""
|
||
body = {
|
||
"amount": {"value": value, "currency": "RUB"},
|
||
"capture": True,
|
||
"payment_method_id": acc_row["pm_id"],
|
||
"description": "Подписка pdf2scan (автопродление, 30 дней)",
|
||
"metadata": {"purpose": "sub", "account_id": account_id, "autorenew": "1",
|
||
"renewal": "1", "email": email},
|
||
}
|
||
if email:
|
||
body["receipt"] = {"customer": {"email": email}, "items": [{
|
||
"description": "Подписка pdf2scan (30 дней)", "quantity": "1.00",
|
||
"amount": {"value": value, "currency": "RUB"},
|
||
"vat_code": 1, "payment_mode": "full_payment", "payment_subject": "service"}]}
|
||
# идемпотентность по периоду подписки — защита от двойного списания при повторе цикла
|
||
idem = hashlib.sha256(f"renew:{account_id}:{int(acc_row.get('sub_until') or 0)}".encode()).hexdigest()
|
||
try:
|
||
real = _yk_request("POST", "/payments", body, idem)
|
||
except Exception as e:
|
||
print(f"renew charge error {account_id}: {e}")
|
||
_bump_renew_fail(account_id)
|
||
return
|
||
pid, status = real.get("id"), real.get("status")
|
||
_db_record_payment(pid, SUB_PRICE_RUB, "RUB", status or "pending", "sub", None, email)
|
||
if status == "succeeded":
|
||
_db_mark_payment_paid(pid, "sub", None, extra=real.get("metadata") or {},
|
||
payment_method=real.get("payment_method"))
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute("UPDATE WEB_ACCOUNTS SET renew_fail=0 WHERE account_id=?", (account_id,))
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
print(f"autorenew OK: {account_id}")
|
||
elif status == "canceled":
|
||
_bump_renew_fail(account_id)
|
||
print(f"autorenew canceled: {account_id}")
|
||
# status pending → активацию довершит вебхук успешного платежа
|
||
|
||
|
||
def _run_autorenew_once():
|
||
if not (YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY):
|
||
return
|
||
now = time.time()
|
||
with sqlite3.connect(DB_PATH, timeout=15) as con:
|
||
con.row_factory = sqlite3.Row
|
||
rows = [dict(r) for r in con.execute(
|
||
"SELECT account_id, sub_until, pm_id, pm_email, renew_fail, last_renew FROM WEB_ACCOUNTS "
|
||
"WHERE autorenew=1 AND pm_id IS NOT NULL AND pm_id != '' "
|
||
"AND sub_until < ? AND sub_until > ? AND renew_fail < 3 AND COALESCE(last_renew,0) < ?",
|
||
(now + 2 * 86400, now - 3 * 86400, now - 12 * 3600))]
|
||
for r in rows:
|
||
try:
|
||
_charge_renewal(r)
|
||
except Exception as e:
|
||
print(f"autorenew row error: {e}")
|
||
|
||
|
||
def _autorenew_loop():
|
||
"""Фоновый цикл: раз в 6ч списывает подписку у аккаунтов с автопродлением,
|
||
у которых срок истекает в ближайшие 2 дня. Списание ДО истечения — без разрыва."""
|
||
time.sleep(120) # дать приложению прогреться
|
||
while True:
|
||
try:
|
||
_run_autorenew_once()
|
||
except Exception as e:
|
||
print(f"autorenew loop error: {e}")
|
||
time.sleep(6 * 3600)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# Аккаунты, сессии, 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
|
||
|
||
|
||
def _verify_sub_token(token: str):
|
||
"""Проверяет подписанный deep-link из бота: '<tgid>.<exp>.<sig>'. Возвращает tgid (int) или None."""
|
||
if not SUB_LINK_SECRET or not token:
|
||
return None
|
||
try:
|
||
tgid_s, exp_s, sig = token.split(".", 2)
|
||
if float(exp_s) < time.time():
|
||
return None
|
||
expect = hmac.new(SUB_LINK_SECRET.encode(), f"{tgid_s}.{exp_s}".encode(), hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(sig, expect):
|
||
return None
|
||
return int(tgid_s)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
@app.get("/subscribe")
|
||
async def subscribe_via_bot(request: Request, token: str = "", name: str = ""):
|
||
"""Deep-link из Telegram-бота: проверяем подпись, логиним как tg:<id> (сессия),
|
||
ведём на главную с авто-открытием окна оплаты подписки."""
|
||
tgid = _verify_sub_token(token)
|
||
if not tgid:
|
||
return RedirectResponse(f"{SITE_BASE_URL}/?login=error", status_code=302)
|
||
account_id = f"tg:{tgid}"
|
||
_db_ensure_account(account_id)
|
||
sess = _db_create_session(account_id, "telegram", (name or "").strip()[:64] or "Telegram", "")
|
||
resp = RedirectResponse(f"{SITE_BASE_URL}/?subscribe=1", status_code=302)
|
||
resp.set_cookie(SESSION_COOKIE, sess, max_age=SESSION_DAYS * 86400,
|
||
httponly=True, secure=True, samesite="lax", path="/")
|
||
return resp
|
||
|
||
|
||
def _verify_pay_link(tg, job, exp, sig):
|
||
"""Проверяет подписанную ссылку оплаты файла из бота (раздельные параметры)."""
|
||
if not SUB_LINK_SECRET:
|
||
return None
|
||
try:
|
||
if float(exp) < time.time():
|
||
return None
|
||
expect = hmac.new(SUB_LINK_SECRET.encode(), f"{tg}:{exp}:{job}".encode(), hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(str(sig), expect):
|
||
return None
|
||
return int(tg)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
@app.get("/pay")
|
||
async def pay_held_file(request: Request, tg: str = "", job: str = "", exp: str = "", sig: str = "", name: str = ""):
|
||
"""Deep-link из бота на оплату придержанного файла: логиним tg:<id> + открываем пейволл по job."""
|
||
tgid = _verify_pay_link(tg, job, exp, sig)
|
||
if not tgid or not job:
|
||
return RedirectResponse(f"{SITE_BASE_URL}/?login=error", status_code=302)
|
||
account_id = f"tg:{tgid}"
|
||
_db_ensure_account(account_id)
|
||
sess = _db_create_session(account_id, "telegram", (name or "").strip()[:64] or "Telegram", "")
|
||
resp = RedirectResponse(f"{SITE_BASE_URL}/?pay_job={_urlparse.quote(job)}", status_code=302)
|
||
resp.set_cookie(SESSION_COOKIE, sess, max_age=SESSION_DAYS * 86400,
|
||
httponly=True, secure=True, samesite="lax", path="/")
|
||
return resp
|
||
|
||
|
||
@app.post("/api/held/release")
|
||
async def held_release(payload: dict, request: Request):
|
||
"""Освободить придержанный файл БЕЗ оплаты — только владельцу с активной подпиской."""
|
||
if not isinstance(payload, dict):
|
||
payload = {}
|
||
acc = _resolve_account(request, payload.get("user_id"))
|
||
job_id = (payload.get("job_id") or "").strip()[:128]
|
||
if not acc or not job_id:
|
||
raise HTTPException(status_code=400, detail="bad request")
|
||
if not _db_sub_active(acc):
|
||
raise HTTPException(status_code=403, detail="Нет активной подписки")
|
||
|
||
def _release():
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
row = con.execute("SELECT user_id, url_res_f FROM FILES WHERE name_or_f=?", (job_id,)).fetchone()
|
||
if not row:
|
||
return False
|
||
uid_owner, urf = row
|
||
if (urf or "").strip() != "awaiting_payment":
|
||
return False
|
||
if acc.startswith("tg:") and str(uid_owner) != acc.split(":", 1)[1]:
|
||
return False
|
||
con.execute("UPDATE FILES SET url_res_f='-' WHERE name_or_f=?", (job_id,))
|
||
con.commit()
|
||
return True
|
||
|
||
ok = await asyncio.to_thread(_release)
|
||
return {"released": bool(ok)}
|
||
|
||
|
||
@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.get("/api/subscription")
|
||
async def subscription_status(request: Request, user_id: int = 0):
|
||
"""Статус подписки для текущего входа: cookie-сессия (Yandex/VK) ИЛИ Telegram user_id."""
|
||
acc = _resolve_account(request, user_id)
|
||
until = _db_sub_until(acc)
|
||
ar = _db_get_autorenew(acc)
|
||
return {"active": until > time.time(), "until": until,
|
||
"price": SUB_PRICE_RUB, "paywall_enabled": PAYWALL_ENABLED,
|
||
"autorenew": ar["autorenew"], "has_method": ar["has_method"]}
|
||
|
||
|
||
@app.post("/api/subscription/autorenew")
|
||
async def subscription_autorenew(payload: dict, request: Request):
|
||
"""Вкл/выкл автопродление. body: {enable: bool, user_id?}."""
|
||
if not isinstance(payload, dict):
|
||
payload = {}
|
||
acc = _resolve_account(request, payload.get("user_id"))
|
||
if not acc:
|
||
raise HTTPException(status_code=401, detail="Нужен вход в аккаунт")
|
||
res = await asyncio.to_thread(_db_set_autorenew_flag, acc, bool(payload.get("enable")))
|
||
return res
|
||
|
||
|
||
@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
|
||
|
||
|
||
# ── OAuth: VK ID (OAuth 2.1 + PKCE) ───────────────────────────────────────────
|
||
def _pkce_pair():
|
||
verifier = secrets.token_urlsafe(48) # 43–128 символов
|
||
challenge = _b64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||
return verifier, challenge
|
||
|
||
|
||
def _vk_token_exchange(code: str, verifier: str, device_id: str) -> dict:
|
||
data = _urlparse.urlencode({
|
||
"grant_type": "authorization_code", "code": code, "code_verifier": verifier,
|
||
"client_id": VK_CLIENT_ID, "device_id": device_id, "redirect_uri": VK_REDIRECT,
|
||
}).encode()
|
||
req = _urlreq.Request("https://id.vk.com/oauth2/auth", data=data, method="POST")
|
||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||
with _urlreq.urlopen(req, timeout=20) as resp:
|
||
return _pjson.loads(resp.read().decode())
|
||
|
||
|
||
def _vk_userinfo(access_token: str) -> dict:
|
||
data = _urlparse.urlencode({"access_token": access_token, "client_id": VK_CLIENT_ID}).encode()
|
||
req = _urlreq.Request("https://id.vk.com/oauth2/user_info", data=data, method="POST")
|
||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||
with _urlreq.urlopen(req, timeout=20) as resp:
|
||
return _pjson.loads(resp.read().decode())
|
||
|
||
|
||
@app.get("/api/auth/vk/start")
|
||
async def vk_start():
|
||
if not VK_CLIENT_ID:
|
||
raise HTTPException(status_code=503, detail="Вход через VK не настроен.")
|
||
state = secrets.token_hex(16)
|
||
verifier, challenge = _pkce_pair()
|
||
url = ("https://id.vk.com/authorize?response_type=code"
|
||
f"&client_id={VK_CLIENT_ID}&redirect_uri={_urlparse.quote(VK_REDIRECT, safe='')}"
|
||
f"&state={state}&code_challenge={challenge}&code_challenge_method=S256&scope=email")
|
||
resp = RedirectResponse(url)
|
||
resp.set_cookie("p2s_vk_state", state, max_age=600, httponly=True, secure=True, samesite="lax", path="/")
|
||
resp.set_cookie("p2s_vk_verifier", verifier, max_age=600, httponly=True, secure=True, samesite="lax", path="/")
|
||
return resp
|
||
|
||
|
||
@app.get("/api/auth/vk/callback")
|
||
async def vk_callback(request: Request, code: str = "", state: str = "", device_id: str = ""):
|
||
if not code:
|
||
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
|
||
if state and request.cookies.get("p2s_vk_state") != state:
|
||
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
|
||
verifier = request.cookies.get("p2s_vk_verifier") or ""
|
||
try:
|
||
tok = await asyncio.to_thread(_vk_token_exchange, code, verifier, device_id)
|
||
access = tok.get("access_token")
|
||
vk_uid = str(tok.get("user_id") or "")
|
||
email = tok.get("email") or ""
|
||
info = await asyncio.to_thread(_vk_userinfo, access) if access else {}
|
||
except Exception as e:
|
||
print(f"vk oauth error: {e}")
|
||
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
|
||
user = (info.get("user") or {}) if isinstance(info, dict) else {}
|
||
if not vk_uid:
|
||
vk_uid = str(user.get("user_id") or "")
|
||
if not vk_uid:
|
||
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
|
||
email = email or user.get("email") or ""
|
||
name = ((user.get("first_name") or "") + " " + (user.get("last_name") or "")).strip() or "VK"
|
||
account_id = f"vk:{vk_uid}"
|
||
_db_ensure_account(account_id)
|
||
token = _db_create_session(account_id, "vk", name, email)
|
||
resp = RedirectResponse(f"{SITE_BASE_URL}/?login=vk")
|
||
resp.set_cookie(SESSION_COOKIE, token, max_age=SESSION_DAYS * 86400,
|
||
httponly=True, secure=True, samesite="lax", path="/")
|
||
resp.delete_cookie("p2s_vk_state", path="/")
|
||
resp.delete_cookie("p2s_vk_verifier", path="/")
|
||
return resp
|
||
|
||
|
||
# ── Вход по почте (magic-code) ────────────────────────────────────────────────
|
||
import smtplib as _smtplib
|
||
import ssl as _ssl
|
||
from email.mime.text import MIMEText as _MIMEText
|
||
|
||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||
_email_rate: dict = {} # email → ts последней отправки (rate-limit)
|
||
|
||
|
||
def _send_email(to: str, subject: str, body: str):
|
||
if not (SMTP_HOST and SMTP_USER and SMTP_PASS):
|
||
raise RuntimeError("SMTP не настроен")
|
||
msg = _MIMEText(body, "plain", "utf-8")
|
||
msg["Subject"] = subject
|
||
msg["From"] = SMTP_FROM
|
||
msg["To"] = to
|
||
ctx = _ssl.create_default_context()
|
||
if SMTP_PORT == 465:
|
||
with _smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx, timeout=20) as s:
|
||
s.login(SMTP_USER, SMTP_PASS)
|
||
s.sendmail(SMTP_FROM, [to], msg.as_string())
|
||
else:
|
||
with _smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
|
||
s.starttls(context=ctx)
|
||
s.login(SMTP_USER, SMTP_PASS)
|
||
s.sendmail(SMTP_FROM, [to], msg.as_string())
|
||
|
||
|
||
@app.post("/api/auth/email/start")
|
||
async def email_start(payload: dict):
|
||
if not (SMTP_HOST and SMTP_USER):
|
||
raise HTTPException(status_code=503, detail="Вход по почте не настроен.")
|
||
email = (payload.get("email") or "").strip().lower()[:128] if isinstance(payload, dict) else ""
|
||
if not _EMAIL_RE.match(email):
|
||
raise HTTPException(status_code=400, detail="Некорректный email")
|
||
now = time.time()
|
||
if now - _email_rate.get(email, 0) < 60:
|
||
raise HTTPException(status_code=429, detail="Код уже отправлен, подождите минуту.")
|
||
code = f"{secrets.randbelow(1000000):06d}"
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"INSERT INTO WEB_EMAIL_CODES (email, code, created_at, attempts) VALUES (?,?,?,0) "
|
||
"ON CONFLICT(email) DO UPDATE SET code=excluded.code, created_at=excluded.created_at, attempts=0",
|
||
(email, code, now))
|
||
con.commit()
|
||
except Exception:
|
||
raise HTTPException(status_code=500, detail="Ошибка БД")
|
||
try:
|
||
await asyncio.to_thread(
|
||
_send_email, email, "Код входа pdf2scan",
|
||
f"Ваш код для входа на pdf2scan.online: {code}\n\nКод действует 10 минут.\nЕсли вы не запрашивали вход — проигнорируйте письмо.")
|
||
except Exception as e:
|
||
print(f"email send error: {e}")
|
||
raise HTTPException(status_code=502, detail="Не удалось отправить письмо. Попробуйте позже.")
|
||
_email_rate[email] = now
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/auth/email/verify")
|
||
async def email_verify(payload: dict):
|
||
email = (payload.get("email") or "").strip().lower()[:128] if isinstance(payload, dict) else ""
|
||
code = (payload.get("code") or "").strip()[:6]
|
||
if not _EMAIL_RE.match(email) or not code.isdigit():
|
||
raise HTTPException(status_code=400, detail="Некорректные данные")
|
||
try:
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
cur = con.execute("SELECT code, created_at, attempts FROM WEB_EMAIL_CODES WHERE email=?", (email,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=400, detail="Код не найден. Запросите новый.")
|
||
saved, created, attempts = row
|
||
if time.time() - (created or 0) > EMAIL_CODE_TTL:
|
||
raise HTTPException(status_code=400, detail="Код истёк. Запросите новый.")
|
||
if (attempts or 0) >= 5:
|
||
raise HTTPException(status_code=429, detail="Слишком много попыток. Запросите новый код.")
|
||
if code != saved:
|
||
con.execute("UPDATE WEB_EMAIL_CODES SET attempts=attempts+1 WHERE email=?", (email,))
|
||
con.commit()
|
||
raise HTTPException(status_code=400, detail="Неверный код")
|
||
con.execute("DELETE FROM WEB_EMAIL_CODES WHERE email=?", (email,))
|
||
con.commit()
|
||
except HTTPException:
|
||
raise
|
||
except Exception:
|
||
raise HTTPException(status_code=500, detail="Ошибка БД")
|
||
account_id = f"email:{email}"
|
||
_db_ensure_account(account_id)
|
||
token = _db_create_session(account_id, "email", email.split("@")[0], email)
|
||
resp = JSONResponse({"ok": True})
|
||
resp.set_cookie(SESSION_COOKIE, token, max_age=SESSION_DAYS * 86400,
|
||
httponly=True, secure=True, samesite="lax", path="/")
|
||
return resp
|
||
|
||
|
||
_API_DOCS_HTML = """<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>pdf2scan API — scan effect PDF API for business</title>
|
||
<meta name="description" content="pdf2scan REST API: add a scan effect, binding, page numbering and PDF/A to PDF and Word files programmatically. API for business integrations.">
|
||
<link rel="canonical" href="https://pdf2scan.online/developers">
|
||
<style>
|
||
:root{--bg:#0b1220;--card:#131c2e;--txt:#e7eefc;--sub:#9fb3d1;--acc:#3b93ff;--code:#0a2540;--border:#22314a;}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:linear-gradient(160deg,#0b1220,#0e1830);color:var(--txt);
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||
line-height:1.6;padding:0 16px}
|
||
.wrap{max-width:860px;margin:0 auto;padding:48px 0 80px}
|
||
a{color:var(--acc);text-decoration:none} a:hover{text-decoration:underline}
|
||
h1{font-size:34px;margin:0 0 6px} h2{font-size:22px;margin:38px 0 12px}
|
||
h3{font-size:17px;margin:22px 0 8px;color:var(--sub)}
|
||
.sub{color:var(--sub);font-size:16px;margin-bottom:8px}
|
||
.badge{display:inline-block;background:#12325a;color:#9cd0ff;border-radius:20px;padding:3px 12px;font-size:13px;margin-bottom:22px}
|
||
.card{background:var(--card);border:1px solid #1e2b45;border-radius:14px;padding:20px 22px;margin:16px 0}
|
||
pre{background:var(--code);border:1px solid #16365c;border-radius:10px;padding:14px 16px;overflow-x:auto;font-size:13.5px}
|
||
code{font-family:'SF Mono',Menlo,Consolas,monospace}
|
||
.meth{display:inline-block;font-weight:700;font-size:12px;padding:2px 9px;border-radius:6px;margin-right:8px;vertical-align:middle}
|
||
.post{background:#1c4a2b;color:#7ee2a1} .get{background:#123a63;color:#7cc0ff}
|
||
table{width:100%;border-collapse:collapse;font-size:14px;margin:8px 0}
|
||
td,th{border-bottom:1px solid #1e2b45;padding:8px 6px;text-align:left;vertical-align:top}
|
||
th{color:var(--sub);font-weight:600}
|
||
.toplink{font-size:14px}
|
||
.foot{margin-top:44px;color:var(--sub);font-size:14px;border-top:1px solid #1e2b45;padding-top:18px}
|
||
.lead{background:#10233f;border-left:3px solid var(--acc);padding:12px 16px;border-radius:8px;margin:14px 0}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="toplink"><a href="/">← pdf2scan.online</a></div>
|
||
<h1>pdf2scan API</h1>
|
||
<div class="badge">REST · JSON · for business</div>
|
||
<p class="sub">Программно превращайте PDF и Word в «скан»: эффект сканирования, сшивка, нумерация, титульный лист, PDF/A. Идеально для юрфирм, бухгалтерий, документооборота и тендерных отделов.</p>
|
||
<p class="sub"><em>Programmatically turn PDF/Word into scanned documents — scan effect, binding, numbering, PDF/A. Built for integrations.</em></p>
|
||
|
||
<div class="lead">
|
||
<strong>Базовый URL / Base URL:</strong> <code>https://pdf2scan.online/api/v1</code><br>
|
||
<strong>Аутентификация:</strong> заголовок <code>Authorization: Bearer <api_key></code> или <code>X-API-Key: <api_key></code><br>
|
||
<strong>Лимит:</strong> 60 запросов/мин на ключ (пробный тариф).
|
||
</div>
|
||
|
||
<h2>1. Получить ключ / Get an API key</h2>
|
||
<div class="card">
|
||
<p>Ключи привязаны к Telegram-аккаунту. Пока сервис бесплатный (тарифы и оплату добавим позже).</p>
|
||
<button id="genkey-btn" onclick="genKey()" style="background:var(--acc);color:#04121f;border:none;border-radius:10px;padding:11px 20px;font-size:15px;font-weight:700;cursor:pointer">🔑 Сгенерировать ключ через Telegram</button>
|
||
<div id="genkey-status" style="margin-top:12px;color:var(--sub);font-size:14px"></div>
|
||
<div id="genkey-result" style="display:none;margin-top:12px">
|
||
<p style="margin:0 0 6px;color:var(--sub);font-size:13px">Ваш ключ (показывается один раз — сохраните!):</p>
|
||
<pre style="margin:0"><code id="genkey-value"></code></pre>
|
||
</div>
|
||
<p style="margin-top:14px">Либо напишите <a href="https://t.me/berkio" target="_blank" rel="noopener">@berkio</a> — выдадим ключ вручную.</p>
|
||
</div>
|
||
|
||
<h2>Тариф / Plan</h2>
|
||
<div class="card">
|
||
<p><strong>Free</strong> — 60 запросов/мин, для проб. <strong>Business</strong> — 300 запросов/мин, приоритет, крупные файлы.</p>
|
||
<p style="margin:6px 0 12px"><strong id="pro-price">{{PRO_PRICE}}</strong> ₽ / 30 дней. Оплата картой через ЮKassa (чек на email).</p>
|
||
<input type="text" id="pay-key" placeholder="ваш API-ключ (sk_...)" style="width:100%;max-width:420px;padding:9px 12px;border:1px solid #22314a;background:#0a2540;color:#e7eefc;border-radius:8px;margin-bottom:8px;font-family:monospace;font-size:13px"><br>
|
||
<input type="email" id="pay-key-email" placeholder="email для чека" style="width:100%;max-width:280px;padding:9px 12px;border:1px solid #22314a;background:#0a2540;color:#e7eefc;border-radius:8px;margin-bottom:10px;font-size:14px"><br>
|
||
<button id="pay-pro-btn" onclick="payPro()" style="background:var(--acc);color:#04121f;border:none;border-radius:10px;padding:11px 22px;font-size:15px;font-weight:700;cursor:pointer">💳 Оплатить Business</button>
|
||
<div id="pay-pro-msg" style="margin-top:10px;color:var(--sub);font-size:14px"></div>
|
||
</div>
|
||
|
||
<h2>2. Отправить файл / Submit a file</h2>
|
||
<div class="card">
|
||
<p><span class="meth post">POST</span><code>/api/v1/scan</code> — multipart/form-data</p>
|
||
<table>
|
||
<tr><th>Поле</th><th>Тип</th><th>По умолчанию</th><th>Описание</th></tr>
|
||
<tr><td>file</td><td>file</td><td>—</td><td>PDF / DOC / DOCX</td></tr>
|
||
<tr><td>stitching</td><td>string</td><td>без сшивки</td><td>нитка · ленточка · уголок · пружинка · дырокол · без сшивки</td></tr>
|
||
<tr><td>color</td><td>string</td><td>color</td><td><code>color</code> или <code>bw</code></td></tr>
|
||
<tr><td>quality</td><td>string</td><td>high</td><td><code>high</code> (300 DPI) · <code>medium</code> (250) · <code>low</code> (200)</td></tr>
|
||
<tr><td>numeration</td><td>0/1</td><td>0</td><td>нумерация страниц</td></tr>
|
||
<tr><td>title</td><td>0/1</td><td>0</td><td>титульный лист</td></tr>
|
||
<tr><td>flip_side</td><td>0/1</td><td>0</td><td>лист «прошито-пронумеровано»</td></tr>
|
||
<tr><td>pdfa</td><td>0/1</td><td>0</td><td>формат PDF/A</td></tr>
|
||
</table>
|
||
<h3>Пример / Example</h3>
|
||
<pre><code>curl -X POST https://pdf2scan.online/api/v1/scan \\
|
||
-H "Authorization: Bearer sk_your_key_here" \\
|
||
-F "file=@document.pdf" \\
|
||
-F "stitching=нитка" \\
|
||
-F "color=bw" \\
|
||
-F "numeration=1" \\
|
||
-F "pdfa=1"</code></pre>
|
||
<h3>Ответ / Response</h3>
|
||
<pre><code>{
|
||
"job_id": "2026.07.02_12-40-11_document",
|
||
"pages": 14,
|
||
"status_url": "/api/v1/scan/2026.07.02_12-40-11_document",
|
||
"download_url": "/api/v1/scan/2026.07.02_12-40-11_document/download"
|
||
}</code></pre>
|
||
</div>
|
||
|
||
<h2>3. Проверить статус / Check status</h2>
|
||
<div class="card">
|
||
<p><span class="meth get">GET</span><code>/api/v1/scan/{job_id}</code></p>
|
||
<pre><code>curl https://pdf2scan.online/api/v1/scan/JOB_ID \\
|
||
-H "Authorization: Bearer sk_your_key_here"
|
||
|
||
# { "status": "queued", "queue_pos": 3 }
|
||
# { "status": "processing", "queue_pos": 0 }
|
||
# { "status": "done", "download_url": "/api/v1/scan/JOB_ID/download" }</code></pre>
|
||
</div>
|
||
|
||
<h2>4. Скачать результат / Download result</h2>
|
||
<div class="card">
|
||
<p><span class="meth get">GET</span><code>/api/v1/scan/{job_id}/download</code> — возвращает PDF (готов ~6 часов).</p>
|
||
<pre><code>curl -L https://pdf2scan.online/api/v1/scan/JOB_ID/download \\
|
||
-H "Authorization: Bearer sk_your_key_here" \\
|
||
-o scan.pdf</code></pre>
|
||
</div>
|
||
|
||
<h2>Коды ошибок / Errors</h2>
|
||
<div class="card">
|
||
<table>
|
||
<tr><th>Код</th><th>Значение</th></tr>
|
||
<tr><td>401</td><td>Нет ключа / ключ неверный или отключён</td></tr>
|
||
<tr><td>409</td><td>Файл ещё не готов (проверьте статус)</td></tr>
|
||
<tr><td>413</td><td>Файл слишком большой</td></tr>
|
||
<tr><td>429</td><td>Превышен лимит запросов</td></tr>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="foot">
|
||
pdf2scan.online · <a href="/">Главная</a> · Вопросы по API — <a href="https://t.me/berkio" target="_blank" rel="noopener">@berkio</a>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
var BOT = 'pdf2scan_pybot';
|
||
var pollTimer = null;
|
||
function setStatus(t){ document.getElementById('genkey-status').textContent = t; }
|
||
async function genKey(){
|
||
var btn = document.getElementById('genkey-btn');
|
||
btn.disabled = true;
|
||
setStatus('Создаём сессию входа...');
|
||
try{
|
||
var r = await fetch('/api/auth/token', {method:'POST'});
|
||
var d = await r.json();
|
||
var token = d.token;
|
||
window.open('https://t.me/' + BOT + '?start=webauth_' + token, '_blank');
|
||
setStatus('Откройте Telegram-бота и нажмите Start — ключ появится здесь автоматически...');
|
||
var tries = 0;
|
||
pollTimer = setInterval(async function(){
|
||
tries++;
|
||
if (tries > 150){ clearInterval(pollTimer); setStatus('Время ожидания истекло. Попробуйте снова.'); btn.disabled=false; return; }
|
||
try{
|
||
var pr = await fetch('/api/auth/poll/' + token);
|
||
if (!pr.ok) return;
|
||
var pd = await pr.json();
|
||
if (pd.status === 'verified'){
|
||
clearInterval(pollTimer);
|
||
setStatus('Вход подтверждён, выпускаем ключ...');
|
||
var fd = new FormData(); fd.append('token', token); fd.append('name', 'web');
|
||
var kr = await fetch('/api/v1/keys', {method:'POST', body: fd});
|
||
var kd = await kr.json();
|
||
if (kd.api_key){
|
||
document.getElementById('genkey-value').textContent = kd.api_key;
|
||
document.getElementById('genkey-result').style.display = 'block';
|
||
var pk = document.getElementById('pay-key'); if (pk) pk.value = kd.api_key;
|
||
setStatus('Готово! Лимит: ' + kd.rate_limit_per_min + ' запросов/мин.');
|
||
} else {
|
||
setStatus('Не удалось выпустить ключ: ' + (kd.detail || 'ошибка'));
|
||
btn.disabled = false;
|
||
}
|
||
} else if (pd.status === 'expired'){
|
||
clearInterval(pollTimer); setStatus('Сессия истекла. Попробуйте снова.'); btn.disabled=false;
|
||
}
|
||
}catch(e){}
|
||
}, 2000);
|
||
}catch(e){
|
||
setStatus('Ошибка: ' + e); btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function payPro(){
|
||
var key = (document.getElementById('pay-key').value||'').trim();
|
||
var email = (document.getElementById('pay-key-email').value||'').trim();
|
||
var msg = document.getElementById('pay-pro-msg');
|
||
var btn = document.getElementById('pay-pro-btn');
|
||
if (!/^sk_/.test(key)){ msg.textContent = 'Укажите ваш API-ключ (sk_...)'; return; }
|
||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.textContent = 'Укажите корректный email для чека'; return; }
|
||
btn.disabled = true; msg.textContent = 'Создаём платёж…';
|
||
try{
|
||
var r = await fetch('/api/pay/create', {method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({purpose:'api_pro', api_key:key, email:email})});
|
||
var d = await r.json();
|
||
if (d.confirmation_url){ localStorage.setItem('pdf2scan_pay_id', d.payment_id); location.href = d.confirmation_url; }
|
||
else { msg.textContent = d.detail || 'Не удалось создать платёж'; btn.disabled = false; }
|
||
}catch(e){ msg.textContent = 'Ошибка сети'; btn.disabled = false; }
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
_ADMIN_HTML = """<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>pdf2scan · админ</title>
|
||
<style>
|
||
:root{ --blue:#007AFF; --green:#34c759; --red:#ff3b30; --bg:#f2f2f7; --card:#fff;
|
||
--ink:#1c1c1e; --sec:#8e8e93; --line:#e5e5ea; }
|
||
*{ box-sizing:border-box; }
|
||
body{ margin:0; background:var(--bg); color:var(--ink); font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; }
|
||
a{ color:var(--blue); text-decoration:none; }
|
||
.wrap{ max-width:1100px; margin:0 auto; padding:16px; }
|
||
.top{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:16px; }
|
||
.top h1{ font-size:20px; margin:0; font-weight:600; }
|
||
.top .upd{ color:var(--sec); font-size:13px; }
|
||
.top .sp{ flex:1; }
|
||
button{ font:inherit; border:none; border-radius:10px; padding:8px 14px; background:var(--blue); color:#fff; cursor:pointer; }
|
||
button.ghost{ background:#fff; color:var(--blue); border:1px solid var(--line); }
|
||
.cards{ display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin-bottom:20px; }
|
||
.card{ background:var(--card); border-radius:16px; padding:16px; }
|
||
.card .lbl{ color:var(--sec); font-size:13px; margin-bottom:6px; }
|
||
.card .val{ font-size:26px; font-weight:600; }
|
||
.card .sub{ color:var(--sec); font-size:12px; margin-top:4px; }
|
||
section{ background:var(--card); border-radius:16px; padding:16px; margin-bottom:20px; overflow:hidden; }
|
||
section h2{ font-size:16px; margin:0 0 12px; font-weight:600; }
|
||
.scroll{ overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
||
table{ width:100%; border-collapse:collapse; font-size:13.5px; white-space:nowrap; }
|
||
th,td{ text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); }
|
||
th{ color:var(--sec); font-weight:500; font-size:12px; }
|
||
tr:last-child td{ border-bottom:none; }
|
||
.num{ text-align:right; font-variant-numeric:tabular-nums; }
|
||
.fname{ max-width:300px; overflow:hidden; text-overflow:ellipsis; }
|
||
.pill{ display:inline-block; padding:2px 8px; border-radius:20px; font-size:12px; }
|
||
.pill.ok{ background:rgba(52,199,89,.15); color:#1f9d3f; }
|
||
.pill.pend{ background:rgba(142,142,147,.15); color:#6b6b70; }
|
||
.pill.cancel{ background:rgba(255,59,48,.14); color:#c1281e; }
|
||
.pill.web{ background:rgba(0,122,255,.12); color:#0060df; }
|
||
.pill.bot{ background:rgba(88,86,214,.14); color:#4b48c4; }
|
||
.bars{ display:flex; align-items:flex-end; gap:5px; height:90px; }
|
||
.bars .b{ flex:1; background:var(--blue); border-radius:4px 4px 0 0; min-height:2px; position:relative; opacity:.85; }
|
||
.bars .b span{ position:absolute; bottom:-18px; left:0; right:0; text-align:center; font-size:10px; color:var(--sec); }
|
||
.bars .b b{ position:absolute; top:-15px; left:0; right:0; text-align:center; font-size:10px; font-weight:600; color:var(--ink); }
|
||
.bars{ margin-top:16px; }
|
||
.muted{ color:var(--sec); }
|
||
#gate{ max-width:360px; margin:80px auto; background:#fff; border-radius:16px; padding:24px; text-align:center; }
|
||
#gate input{ width:100%; padding:11px 12px; border:1px solid var(--line); border-radius:10px; font:inherit; margin:14px 0; }
|
||
#gate .err{ color:var(--red); font-size:13px; min-height:18px; }
|
||
.hide{ display:none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="gate" class="hide">
|
||
<h1 style="font-size:19px;margin:0">Админ-панель</h1>
|
||
<p class="muted" style="font-size:13px">Введите админ-токен</p>
|
||
<input id="gate-token" type="password" placeholder="admin token" autocomplete="off">
|
||
<div class="err" id="gate-err"></div>
|
||
<button onclick="saveToken()" style="width:100%">Войти</button>
|
||
</div>
|
||
|
||
<div id="dash" class="wrap hide">
|
||
<div class="top">
|
||
<h1>pdf2scan · админ</h1>
|
||
<span class="upd" id="upd"></span>
|
||
<span class="sp"></span>
|
||
<button class="ghost" onclick="load()">Обновить</button>
|
||
<button class="ghost" onclick="logout()">Выйти</button>
|
||
</div>
|
||
|
||
<div class="cards">
|
||
<div class="card"><div class="lbl">Активные подписки</div><div class="val" id="m-subs">—</div></div>
|
||
<div class="card"><div class="lbl">Доход (успешно)</div><div class="val" id="m-rev">—</div><div class="sub" id="m-rev-sub"></div></div>
|
||
<div class="card"><div class="lbl">Заходы сегодня</div><div class="val" id="m-vis">—</div><div class="sub" id="m-vis-sub"></div></div>
|
||
<div class="card"><div class="lbl">Документов обработано</div><div class="val" id="m-docs">—</div><div class="sub" id="m-docs-sub"></div></div>
|
||
<div class="card"><div class="lbl">Общее время обработки</div><div class="val" id="m-time">—</div><div class="sub" id="m-time-sub"></div></div>
|
||
<div class="card"><div class="lbl">Среднее на страницу</div><div class="val" id="m-avg">—</div><div class="sub" id="m-avg-sub">время обработки 1 стр.</div></div>
|
||
</div>
|
||
|
||
<section>
|
||
<h2>Активные подписки</h2>
|
||
<div class="scroll"><table><thead><tr><th>Аккаунт</th><th>Активна до</th><th class="num">Осталось</th></tr></thead>
|
||
<tbody id="t-subs"></tbody></table></div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Заходы на сайт <span class="muted" id="vis-total"></span></h2>
|
||
<div class="bars" id="bars"></div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>По дням</h2>
|
||
<div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Заходы</th><th class="num">Уник.</th><th class="num">Файлов</th><th class="num">Страниц</th><th class="num">Ср/стр</th><th class="num">Исходник</th><th class="num">Результат</th></tr></thead>
|
||
<tbody id="t-daily"></tbody></table></div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Платежи <span class="muted" id="pay-breakdown"></span></h2>
|
||
<div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Сумма</th><th>Назначение</th><th>Статус</th><th>Email</th></tr></thead>
|
||
<tbody id="t-pays"></tbody></table></div>
|
||
</section>
|
||
|
||
<section>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||
<h2 style="margin:0">Обработанные документы <span class="muted" id="docs-totals"></span></h2>
|
||
<button class="ghost" onclick="downloadCsv()">Экспорт CSV</button>
|
||
</div>
|
||
<div class="scroll"><table><thead><tr><th>Дата</th><th>Файл</th><th class="num">Стр.</th><th class="num">Время</th><th class="num">Исходник</th><th class="num">Результат</th><th>Кач-во</th><th>Сшивка</th><th>OCR</th><th>Источник</th></tr></thead>
|
||
<tbody id="t-docs"></tbody></table></div>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
var TKEY = 'p2s_admin_token';
|
||
function getToken(){ return localStorage.getItem(TKEY) || ''; }
|
||
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
|
||
function money(v){ return Math.round(v||0).toLocaleString('ru-RU') + ' ₽'; }
|
||
function size(mb){ if(mb==null) return '—'; mb=+mb; if(!mb) return '—'; return mb>=1024 ? (mb/1024).toFixed(2)+' ГБ' : mb.toFixed(1)+' МБ'; }
|
||
function dur(sec){ sec=Math.round(sec||0); if(sec<60) return sec+' с'; var m=Math.floor(sec/60); if(m<60) return m+' мин'; var h=Math.floor(m/60); if(h<24) return h+' ч '+(m%60)+' мин'; return Math.floor(h/24)+' дн '+(h%24)+' ч'; }
|
||
function psec(s){ if(s==null) return '—'; s=+s; if(s<60) return s.toFixed(1)+' с'; var m=Math.floor(s/60); return m+' м '+Math.round(s%60)+' с'; }
|
||
function dts(ts){ if(!ts) return '—'; var d=new Date(ts*1000); return d.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}); }
|
||
function dOnly(ts){ if(!ts) return '—'; var d=new Date(ts*1000); return d.toLocaleDateString('ru-RU'); }
|
||
function daysLeft(ts){ var s=Math.max(0,(ts*1000-Date.now())/86400000); return s>=365 ? Math.round(s/365)+' г.' : Math.round(s)+' дн.'; }
|
||
|
||
var PURP = { donate:'Донат', api_pro:'API-тариф', file:'Файл', sub:'Подписка' };
|
||
var STAT = { succeeded:['ok','оплачен'], pending:['pend','ждём'], canceled:['cancel','отмена'] };
|
||
|
||
async function load(){
|
||
var t = getToken();
|
||
if(!t){ showGate(); return; }
|
||
try{
|
||
var r = await fetch('/api/admin/stats', { headers: { 'X-Admin-Token': t } });
|
||
if(r.status === 401){ showGate('Неверный токен'); return; }
|
||
var d = await r.json();
|
||
render(d);
|
||
}catch(e){ showGate('Ошибка сети'); }
|
||
}
|
||
|
||
function render(d){
|
||
document.getElementById('gate').classList.add('hide');
|
||
document.getElementById('dash').classList.remove('hide');
|
||
document.getElementById('upd').textContent = 'обновлено ' + dts(d.generated_at);
|
||
|
||
document.getElementById('m-subs').textContent = d.subs.active_count;
|
||
document.getElementById('m-rev').textContent = money(d.payments.total_succeeded);
|
||
document.getElementById('m-rev-sub').textContent = d.payments.count_succeeded + ' платежей';
|
||
document.getElementById('m-vis').textContent = (d.visits.today||0).toLocaleString('ru-RU');
|
||
document.getElementById('m-vis-sub').textContent = 'уник. ' + (d.visits.unique_today||0).toLocaleString('ru-RU') + ' · 7 дн. ' + (d.visits.last7||0).toLocaleString('ru-RU');
|
||
document.getElementById('m-docs').textContent = (d.docs.total_count||0).toLocaleString('ru-RU');
|
||
document.getElementById('m-docs-sub').textContent = (d.docs.total_pages||0).toLocaleString('ru-RU') + ' стр. · ' + size(d.docs.total_result_mb);
|
||
document.getElementById('m-time').textContent = dur(d.docs.total_seconds);
|
||
document.getElementById('m-time-sub').textContent = 'суммарно по всем файлам';
|
||
document.getElementById('m-avg').textContent = (d.docs.avg_page_seconds||0).toFixed(1) + ' с';
|
||
|
||
// Подписки
|
||
var subs = d.subs.list || [];
|
||
document.getElementById('t-subs').innerHTML = subs.length ? subs.map(function(s){
|
||
return '<tr><td>'+esc(s.account_id)+'</td><td>'+dOnly(s.sub_until)+'</td><td class="num">'+daysLeft(s.sub_until)+'</td></tr>';
|
||
}).join('') : '<tr><td colspan="3" class="muted">Нет активных подписок</td></tr>';
|
||
|
||
// Заходы (мини-бары)
|
||
var days = (d.visits.days||[]).slice(0,14).reverse();
|
||
var mx = Math.max(1, Math.max.apply(null, days.map(function(x){ return x.count; }).concat([1])));
|
||
document.getElementById('bars').innerHTML = days.length ? days.map(function(x){
|
||
var h = Math.round(x.count/mx*100);
|
||
var dd = x.day.slice(8)+'.'+x.day.slice(5,7);
|
||
return '<div class="b" style="height:'+h+'%" title="'+x.day+': '+x.count+' заходов, уник. '+(x.uniques||0)+'"><b>'+x.count+'</b><span>'+dd+'</span></div>';
|
||
}).join('') : '<div class="muted">Пока нет данных</div>';
|
||
document.getElementById('vis-total').textContent = '· всего ' + (d.visits.total||0).toLocaleString('ru-RU') +
|
||
' (уник. ' + (d.visits.unique_total||0).toLocaleString('ru-RU') + ')';
|
||
|
||
// По дням (свежие сверху)
|
||
var daily = (d.daily||[]).slice().reverse();
|
||
document.getElementById('t-daily').innerHTML = daily.length ? daily.map(function(x){
|
||
var dd = x.day.slice(8)+'.'+x.day.slice(5,7)+'.'+x.day.slice(0,4);
|
||
return '<tr><td>'+dd+'</td><td class="num">'+(x.visits||0)+'</td><td class="num">'+(x.uniques||0)+
|
||
'</td><td class="num">'+(x.files||0)+'</td><td class="num">'+(x.pages||0).toLocaleString('ru-RU')+
|
||
'</td><td class="num">'+(x.avg_page?psec(x.avg_page):'—')+
|
||
'</td><td class="num">'+size(x.src_mb)+'</td><td class="num">'+size(x.res_mb)+'</td></tr>';
|
||
}).join('') : '<tr><td colspan="8" class="muted">Пока нет данных</td></tr>';
|
||
|
||
// Платежи
|
||
var bp = d.payments.by_purpose || {};
|
||
document.getElementById('pay-breakdown').textContent = '· ' + Object.keys(bp).map(function(k){
|
||
return (PURP[k]||k)+': '+money(bp[k].sum); }).join(', ');
|
||
var pays = d.payments.recent || [];
|
||
document.getElementById('t-pays').innerHTML = pays.length ? pays.map(function(p){
|
||
var st = STAT[p.status] || ['pend', p.status];
|
||
return '<tr><td>'+dts(p.created_at)+'</td><td class="num">'+money(p.amount)+'</td><td>'+(PURP[p.purpose]||esc(p.purpose))+
|
||
'</td><td><span class="pill '+st[0]+'">'+st[1]+'</span></td><td class="muted">'+esc(p.email||'—')+'</td></tr>';
|
||
}).join('') : '<tr><td colspan="5" class="muted">Пока нет платежей</td></tr>';
|
||
|
||
// Документы
|
||
document.getElementById('docs-totals').textContent = '· всего ' + (d.docs.total_count||0).toLocaleString('ru-RU') +
|
||
', ' + (d.docs.total_pages||0).toLocaleString('ru-RU') + ' стр., исх. ' + size(d.docs.total_source_mb);
|
||
var wid = d.web_user_id;
|
||
var docs = d.docs.recent || [];
|
||
document.getElementById('t-docs').innerHTML = docs.length ? docs.map(function(f){
|
||
var web = (f.user_id === wid);
|
||
var srcPill = web ? '<span class="pill web">сайт</span>' : '<span class="pill bot">бот</span>';
|
||
var dd = (f.date||'') + (f.time ? ' '+String(f.time).slice(0,5) : '');
|
||
return '<tr><td>'+esc(dd)+'</td><td class="fname" title="'+esc(f.name)+'">'+esc(f.name)+'</td><td class="num">'+(f.pages||'—')+
|
||
'</td><td class="num">'+psec(f.secs)+'</td><td class="num">'+size(f.source_size_mb)+'</td><td class="num">'+size(f.result_size_mb)+
|
||
'</td><td>'+esc(f.quality||'—')+'</td><td>'+esc(f.stitching||'—')+'</td><td>'+(f.ocr?'✓':'')+'</td><td>'+srcPill+'</td></tr>';
|
||
}).join('') : '<tr><td colspan="10" class="muted">Пока нет документов</td></tr>';
|
||
}
|
||
|
||
function showGate(err){
|
||
document.getElementById('dash').classList.add('hide');
|
||
document.getElementById('gate').classList.remove('hide');
|
||
document.getElementById('gate-err').textContent = err || '';
|
||
}
|
||
function saveToken(){
|
||
var v = (document.getElementById('gate-token').value||'').trim();
|
||
if(!v){ return; }
|
||
localStorage.setItem(TKEY, v); load();
|
||
}
|
||
function logout(){ localStorage.removeItem(TKEY); showGate(); }
|
||
function downloadCsv(){
|
||
var t = getToken(); if(!t){ showGate(); return; }
|
||
window.location.href = '/api/admin/docs.csv?token=' + encodeURIComponent(t);
|
||
}
|
||
|
||
(function init(){
|
||
var m = location.search.match(/[?&]token=([^&]+)/);
|
||
if(m){ localStorage.setItem(TKEY, decodeURIComponent(m[1])); history.replaceState(null,'','/admin'); }
|
||
document.getElementById('gate-token') && document.getElementById('gate-token').addEventListener('keydown', function(e){ if(e.key==='Enter') saveToken(); });
|
||
load();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
# ── Админ-панель ──────────────────────────────────────────────────────────────
|
||
def _bump_visit(vid: str | None = None):
|
||
"""+1 к счётчику заходов за сегодня; vid — id браузера для подсчёта уникальных."""
|
||
try:
|
||
day = datetime.datetime.now().strftime("%Y-%m-%d")
|
||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||
con.execute(
|
||
"INSERT INTO WEB_VISITS (day, count) VALUES (?, 1) "
|
||
"ON CONFLICT(day) DO UPDATE SET count = count + 1",
|
||
(day,),
|
||
)
|
||
if vid:
|
||
con.execute("INSERT OR IGNORE INTO WEB_VISIT_IDS (day, vid) VALUES (?, ?)", (day, vid))
|
||
con.commit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@app.post("/api/hit")
|
||
async def api_hit(request: Request):
|
||
"""Маячок посещения: JS вызывает раз в сессию браузера. Боты/health-check без JS не считаются.
|
||
Cookie p2s_vid (2 года) даёт подсчёт уникальных посетителей."""
|
||
vid = request.cookies.get("p2s_vid") or ""
|
||
new_vid = False
|
||
if len(vid) < 8:
|
||
vid = secrets.token_urlsafe(12)
|
||
new_vid = True
|
||
await asyncio.to_thread(_bump_visit, vid)
|
||
resp = JSONResponse({"ok": True})
|
||
if new_vid:
|
||
resp.set_cookie("p2s_vid", vid, max_age=60 * 60 * 24 * 730,
|
||
httponly=True, secure=True, samesite="lax", path="/")
|
||
return resp
|
||
|
||
|
||
def _require_admin(request: Request) -> bool:
|
||
if not ADMIN_TOKEN:
|
||
return False
|
||
tok = request.headers.get("X-Admin-Token") or request.query_params.get("token") or ""
|
||
try:
|
||
return hmac.compare_digest(str(tok), ADMIN_TOKEN)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _elapsed_to_sec(et):
|
||
"""Парсит elapsed_time ('H:MM:SS.ffffff') в секунды; None если не распарсилось."""
|
||
if not et:
|
||
return None
|
||
try:
|
||
pp = str(et).split(":")
|
||
if len(pp) == 3:
|
||
return int(pp[0]) * 3600 + int(pp[1]) * 60 + float(pp[2])
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _admin_collect_stats() -> dict:
|
||
now = time.time()
|
||
out = {"generated_at": now, "web_user_id": WEB_USER_ID}
|
||
with sqlite3.connect(DB_PATH, timeout=15) as con:
|
||
con.row_factory = sqlite3.Row
|
||
# Активные подписки
|
||
subs = [dict(r) for r in con.execute(
|
||
"SELECT account_id, sub_until FROM WEB_ACCOUNTS WHERE sub_until > ? ORDER BY sub_until DESC",
|
||
(now,))]
|
||
out["subs"] = {"active_count": len(subs), "list": subs[:300]}
|
||
# Платежи
|
||
pays = [dict(r) for r in con.execute(
|
||
"SELECT payment_id, amount, currency, status, purpose, email, created_at, paid_at "
|
||
"FROM WEB_PAYMENTS ORDER BY created_at DESC LIMIT 100")]
|
||
srow = con.execute(
|
||
"SELECT COUNT(*) c, COALESCE(SUM(amount),0) s FROM WEB_PAYMENTS WHERE status='succeeded'").fetchone()
|
||
by_purpose = {r["purpose"]: {"count": r["c"], "sum": r["s"]} for r in con.execute(
|
||
"SELECT purpose, COUNT(*) c, COALESCE(SUM(amount),0) s FROM WEB_PAYMENTS "
|
||
"WHERE status='succeeded' GROUP BY purpose")}
|
||
out["payments"] = {"recent": pays, "count_succeeded": srow["c"],
|
||
"total_succeeded": srow["s"], "by_purpose": by_purpose}
|
||
# Заходы + уникальные посетители
|
||
days = [dict(r) for r in con.execute(
|
||
"SELECT day, count FROM WEB_VISITS ORDER BY day DESC LIMIT 30")]
|
||
udays = {r["day"]: r["u"] for r in con.execute(
|
||
"SELECT day, COUNT(*) u FROM WEB_VISIT_IDS GROUP BY day")}
|
||
for d in days:
|
||
d["uniques"] = udays.get(d["day"], 0)
|
||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||
today_c = next((d["count"] for d in days if d["day"] == today), 0)
|
||
last7 = sum(d["count"] for d in days[:7])
|
||
total = con.execute("SELECT COALESCE(SUM(count),0) s FROM WEB_VISITS").fetchone()["s"]
|
||
uniq_total = con.execute("SELECT COUNT(DISTINCT vid) c FROM WEB_VISIT_IDS").fetchone()["c"]
|
||
uniq_today = udays.get(today, 0)
|
||
uniq_last7 = sum(udays.get(d["day"], 0) for d in days[:7])
|
||
out["visits"] = {"today": today_c, "last7": last7, "total": total, "days": days,
|
||
"unique_today": uniq_today, "unique_last7": uniq_last7, "unique_total": uniq_total}
|
||
# Документы
|
||
cols = {r[1] for r in con.execute("PRAGMA table_info(FILES)")}
|
||
pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)"
|
||
docs = [dict(r) for r in con.execute(
|
||
f"SELECT date, time, COALESCE(NULLIF(original_name,''), name_or_f) AS name, {pages_expr} AS pages, "
|
||
"source_size_mb, result_size_mb, quality, stitching, ocr, elapsed_time, user_id, url_res_f "
|
||
"FROM FILES ORDER BY rowid DESC LIMIT 150")]
|
||
for _d in docs:
|
||
_d["secs"] = _elapsed_to_sec(_d.get("elapsed_time"))
|
||
agg = con.execute(
|
||
f"SELECT COUNT(*) c, COALESCE(SUM({pages_expr}),0) p, "
|
||
"COALESCE(SUM(source_size_mb),0) ssz, COALESCE(SUM(result_size_mb),0) rsz FROM FILES").fetchone()
|
||
# Общее время обработки + среднее на страницу (elapsed_time = 'H:MM:SS.ffffff')
|
||
tot_sec = 0.0
|
||
pages_timed = 0
|
||
for r in con.execute(
|
||
f"SELECT elapsed_time AS et, {pages_expr} AS pg FROM FILES "
|
||
"WHERE elapsed_time IS NOT NULL AND TRIM(elapsed_time) NOT IN ('','-')"):
|
||
s = _elapsed_to_sec(r["et"])
|
||
if s is not None:
|
||
tot_sec += s
|
||
pages_timed += (r["pg"] or 0)
|
||
out["docs"] = {"recent": docs, "total_count": agg["c"], "total_pages": agg["p"],
|
||
"total_source_mb": agg["ssz"], "total_result_mb": agg["rsz"],
|
||
"total_seconds": tot_sec, "avg_page_seconds": (tot_sec / pages_timed) if pages_timed else 0}
|
||
# По дням: заходы/уник + файлы/страницы/размеры (последние 14 дней)
|
||
_today = datetime.date.today()
|
||
_cutoff = (_today - datetime.timedelta(days=13)).strftime("%Y.%m.%d")
|
||
files_by_day = {}
|
||
for r in con.execute(
|
||
f"SELECT date AS d, {pages_expr} AS pg, source_size_mb AS s, result_size_mb AS rs, elapsed_time AS et "
|
||
"FROM FILES WHERE date >= ?", (_cutoff,)):
|
||
key = (r["d"] or "").replace(".", "-")
|
||
e = files_by_day.setdefault(key, {"files": 0, "pages": 0, "src_mb": 0.0,
|
||
"res_mb": 0.0, "secs": 0.0, "pages_timed": 0})
|
||
e["files"] += 1
|
||
e["pages"] += (r["pg"] or 0)
|
||
e["src_mb"] += (r["s"] or 0)
|
||
e["res_mb"] += (r["rs"] or 0)
|
||
sec = _elapsed_to_sec(r["et"])
|
||
if sec is not None:
|
||
e["secs"] += sec
|
||
e["pages_timed"] += (r["pg"] or 0)
|
||
daily = []
|
||
for i in range(13, -1, -1):
|
||
dkey = (_today - datetime.timedelta(days=i)).strftime("%Y-%m-%d")
|
||
vc = next((x["count"] for x in days if x["day"] == dkey), 0)
|
||
fb = files_by_day.get(dkey, {})
|
||
ptimed = fb.get("pages_timed", 0)
|
||
daily.append({"day": dkey, "visits": vc, "uniques": udays.get(dkey, 0),
|
||
"files": fb.get("files", 0), "pages": fb.get("pages", 0),
|
||
"src_mb": fb.get("src_mb", 0), "res_mb": fb.get("res_mb", 0),
|
||
"avg_page": (fb.get("secs", 0) / ptimed) if ptimed else 0})
|
||
out["daily"] = daily
|
||
return out
|
||
|
||
|
||
@app.get("/api/admin/stats")
|
||
async def admin_stats(request: Request):
|
||
if not _require_admin(request):
|
||
raise HTTPException(status_code=401, detail="unauthorized")
|
||
return await asyncio.to_thread(_admin_collect_stats)
|
||
|
||
|
||
def _build_docs_csv() -> bytes:
|
||
import csv, io
|
||
buf = io.StringIO()
|
||
w = csv.writer(buf, delimiter=";")
|
||
w.writerow(["Дата", "Время", "Файл", "Страниц", "Время обраб., с", "Исходник, МБ",
|
||
"Результат, МБ", "Качество", "Сшивка", "OCR", "Источник", "Статус"])
|
||
def _mb(v):
|
||
return ("%.2f" % v) if v else ""
|
||
with sqlite3.connect(DB_PATH, timeout=30) as con:
|
||
con.row_factory = sqlite3.Row
|
||
cols = {r[1] for r in con.execute("PRAGMA table_info(FILES)")}
|
||
pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)"
|
||
for r in con.execute(
|
||
f"SELECT date, time, COALESCE(NULLIF(original_name,''), name_or_f) AS name, {pages_expr} AS pages, "
|
||
"source_size_mb, result_size_mb, quality, stitching, ocr, elapsed_time, user_id, url_res_f FROM FILES ORDER BY rowid DESC"):
|
||
src = "сайт" if (r["user_id"] == WEB_USER_ID) else "бот"
|
||
_et = _elapsed_to_sec(r["elapsed_time"])
|
||
w.writerow([r["date"] or "", (r["time"] or "")[:8], r["name"] or "", r["pages"] or "",
|
||
("%.1f" % _et) if _et is not None else "",
|
||
_mb(r["source_size_mb"]), _mb(r["result_size_mb"]), r["quality"] or "",
|
||
r["stitching"] or "", "да" if r["ocr"] else "", src, r["url_res_f"] or ""])
|
||
return ("" + buf.getvalue()).encode("utf-8") # BOM → кириллица в Excel
|
||
|
||
|
||
@app.get("/api/admin/docs.csv")
|
||
async def admin_docs_csv(request: Request):
|
||
if not _require_admin(request):
|
||
raise HTTPException(status_code=401, detail="unauthorized")
|
||
data = await asyncio.to_thread(_build_docs_csv)
|
||
return Response(content=data, media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": "attachment; filename=pdf2scan_documents.csv"})
|
||
|
||
|
||
@app.get("/admin", response_class=HTMLResponse)
|
||
@app.get("/admin/", response_class=HTMLResponse)
|
||
async def admin_page():
|
||
return _ADMIN_HTML
|
||
|
||
|
||
@app.get("/developers", response_class=HTMLResponse)
|
||
@app.get("/api-docs", response_class=HTMLResponse)
|
||
async def developers_docs():
|
||
return _API_DOCS_HTML.replace("{{PRO_PRICE}}", str(int(API_PRO_PRICE_RUB)))
|