Админка + индикатор подписки + автопродление + де-брендинг входа
- /admin: дашборд (активные подписки, платежи, заходы, документы), доступ по ADMIN_TOKEN - Уникальные посетители (cookie p2s_vid) + экспорт документов в CSV - Индикатор активной подписки: бейдж в шапке + карточка на главной (/api/subscription) - Автопродление ЮKassa: save_payment_method + рекуррентные списания + фоновый цикл - Кнопка входа де-брендирована (нейтральная иконка, «Войти / Регистрация») Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
576
app/main.py
576
app/main.py
@@ -23,7 +23,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
@@ -66,6 +66,9 @@ SESSION_DAYS = 30
|
||||
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", "")
|
||||
|
||||
# ── Вход по почте (magic-code через SMTP) ─────────────────────────────────────
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
|
||||
@@ -296,6 +299,21 @@ def _db_ensure_auth_table():
|
||||
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 (
|
||||
@@ -330,6 +348,17 @@ def _db_ensure_auth_table():
|
||||
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}")
|
||||
con.commit()
|
||||
except Exception as e:
|
||||
print(f"WEB_AUTH_TOKENS create error: {e}")
|
||||
@@ -495,6 +524,7 @@ async def _startup():
|
||||
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")
|
||||
@@ -1633,16 +1663,21 @@ def _resolve_account(request, user_id=0) -> str | None:
|
||||
return _account_id(user_id)
|
||||
|
||||
|
||||
def _db_sub_active(account_id: str | None) -> bool:
|
||||
def _db_sub_until(account_id: str | None) -> float:
|
||||
"""Время окончания подписки (unix) или 0."""
|
||||
if not account_id:
|
||||
return False
|
||||
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 bool(row) and (row[0] or 0) > time.time()
|
||||
return float(row[0] or 0) if row else 0.0
|
||||
except Exception:
|
||||
return False
|
||||
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):
|
||||
@@ -1661,8 +1696,58 @@ def _db_activate_sub(account_id: str, days: int):
|
||||
con.commit()
|
||||
|
||||
|
||||
def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None):
|
||||
"""extra: metadata платежа (job_id для file, account_id для sub)."""
|
||||
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 _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:
|
||||
@@ -1681,6 +1766,10 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None):
|
||||
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":
|
||||
@@ -1701,6 +1790,7 @@ async def pay_create(payload: dict, request: Request):
|
||||
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
|
||||
@@ -1733,8 +1823,11 @@ async def pay_create(payload: dict, request: Request):
|
||||
"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 ""},
|
||||
"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},
|
||||
@@ -1769,7 +1862,7 @@ async def yookassa_webhook(payload: dict):
|
||||
amount = float((real.get("amount") or {}).get("value") or 0)
|
||||
_db_record_payment(pid, amount, "RUB", status, purpose, api_key, meta.get("email") or "")
|
||||
if status == "succeeded":
|
||||
_db_mark_payment_paid(pid, purpose, api_key, extra=meta)
|
||||
_db_mark_payment_paid(pid, purpose, api_key, extra=meta, payment_method=real.get("payment_method"))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -1800,7 +1893,7 @@ async def pay_status(payment_id: str):
|
||||
rk = meta.get("api_key") or api_key
|
||||
_db_record_payment(payment_id, amount, "RUB", status, rp, rk, meta.get("email") or "")
|
||||
if status == "succeeded":
|
||||
_db_mark_payment_paid(payment_id, rp, rk, extra=meta)
|
||||
_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}
|
||||
@@ -1833,6 +1926,97 @@ async def paywall_check(payload: dict, request: Request):
|
||||
}
|
||||
|
||||
|
||||
# ── Авто-продление подписки (рекуррентные списания Ю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)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1956,6 +2140,29 @@ async def me(request: Request):
|
||||
"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))
|
||||
@@ -2336,6 +2543,355 @@ _API_DOCS_HTML = """<!DOCTYPE html>
|
||||
</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; }
|
||||
.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); }
|
||||
.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>
|
||||
|
||||
<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>Платежи <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>Кач-во</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 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);
|
||||
|
||||
// Подписки
|
||||
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)+'"><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 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>'+esc(f.name)+'</td><td class="num">'+(f.pages||'—')+
|
||||
'</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>'+(f.ocr?'✓':'')+'</td><td>'+srcPill+'</td></tr>';
|
||||
}).join('') : '<tr><td colspan="8" 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 _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, ocr, user_id, url_res_f "
|
||||
"FROM FILES ORDER BY rowid DESC LIMIT 150")]
|
||||
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()
|
||||
out["docs"] = {"recent": docs, "total_count": agg["c"], "total_pages": agg["p"],
|
||||
"total_source_mb": agg["ssz"], "total_result_mb": agg["rsz"]}
|
||||
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, ocr, user_id, url_res_f FROM FILES ORDER BY rowid DESC"):
|
||||
src = "сайт" if (r["user_id"] == WEB_USER_ID) else "бот"
|
||||
w.writerow([r["date"] or "", (r["time"] or "")[:8], r["name"] or "", r["pages"] or "",
|
||||
_mb(r["source_size_mb"]), _mb(r["result_size_mb"]), r["quality"] 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():
|
||||
|
||||
@@ -850,8 +850,8 @@
|
||||
|
||||
<div id="header-auth" style="position:relative;">
|
||||
<button id="tg-login-header-btn" onclick="toggleLoginMenu(event)" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L8.32 13.617l-2.96-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.828.942z"/></svg>
|
||||
<span data-i18n="login_btn">Войти</span>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
<span data-i18n="login_btn">Войти / Регистрация</span>
|
||||
</button>
|
||||
<div id="login-menu" style="display:none;position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid var(--border,#d0d7e2);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.12);padding:6px;z-index:50;min-width:190px;">
|
||||
<button onclick="startTgAuth();closeLoginMenu();" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
|
||||
@@ -874,6 +874,10 @@
|
||||
<div id="tg-user-info" style="display:none;">
|
||||
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
|
||||
<span id="tg-user-name"></span>
|
||||
<a id="sub-badge" href="javascript:void(0)" onclick="scrollToSubCard()" title=""
|
||||
style="display:none;align-items:center;gap:5px;background:rgba(52,199,89,.16);color:#1f9d3f;font-size:12.5px;font-weight:700;padding:4px 10px;border-radius:20px;text-decoration:none;white-space:nowrap;">
|
||||
<span style="font-size:13px;line-height:1">★</span><span id="sub-badge-text"></span>
|
||||
</a>
|
||||
<button id="tg-logout-btn" onclick="doLogout()" data-i18n="logout_btn">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -910,6 +914,27 @@
|
||||
|
||||
<main>
|
||||
|
||||
<!-- Статус активной подписки -->
|
||||
<div class="card" id="sub-card" style="display:none;border:1px solid rgba(52,199,89,.4);background:linear-gradient(180deg,rgba(52,199,89,.09),rgba(52,199,89,.02));">
|
||||
<div style="padding:16px;display:flex;align-items:center;gap:14px;">
|
||||
<div style="width:46px;height:46px;flex:none;border-radius:50%;background:rgba(52,199,89,.18);display:flex;align-items:center;justify-content:center;font-size:23px;color:#1f9d3f;">★</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:16px;font-weight:700;color:var(--text-primary,#1c2b45)" data-i18n="sub_active_title">Подписка активна</div>
|
||||
<div id="sub-card-until" style="font-size:13.5px;color:var(--text-secondary)"></div>
|
||||
</div>
|
||||
<button id="sub-renew-btn" type="button" onclick="renewSub()" style="flex:none;background:#34c759;color:#fff;border:none;border-radius:10px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="sub_renew">Продлить</button>
|
||||
</div>
|
||||
<div style="padding:0 16px 14px;display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--text-secondary)">
|
||||
<span>✓ <span data-i18n="sub_perk_files">Большие файлы без лимита</span></span>
|
||||
<span>✓ <span data-i18n="sub_perk_batch">Пакетная обработка</span></span>
|
||||
<span>✓ <span data-i18n="sub_perk_priority">Приоритетная очередь</span></span>
|
||||
</div>
|
||||
<div id="sub-autorenew" style="padding:10px 16px;border-top:0.5px solid rgba(52,199,89,.25);font-size:12.5px;color:var(--text-secondary);display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span id="sub-autorenew-label"></span>
|
||||
<a id="sub-autorenew-toggle" href="javascript:void(0)" onclick="toggleAutorenew()" style="color:#007AFF;text-decoration:underline;cursor:pointer;display:none"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p data-i18n="page_description" style="font-size:13px; line-height:1.45; color:var(--text-secondary); padding: 0 4px;">
|
||||
🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.
|
||||
@@ -1065,6 +1090,10 @@
|
||||
<button id="paywall-file-btn" onclick="payForFile()" style="flex:1;min-width:150px;padding:12px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_file">Оплатить файл</span> · <span id="paywall-file-price">99</span> ₽</button>
|
||||
<button id="paywall-sub-btn" onclick="payForSub()" style="flex:1;min-width:150px;padding:12px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_sub">Подписка</span> · <span id="paywall-sub-price">299</span> ₽/<span data-i18n="paywall_month">мес</span></button>
|
||||
</div>
|
||||
<label id="paywall-autorenew-row" style="display:flex;align-items:center;justify-content:center;gap:7px;margin-top:12px;font-size:12.5px;color:var(--text-secondary);cursor:pointer">
|
||||
<input type="checkbox" id="paywall-autorenew" checked style="width:16px;height:16px;cursor:pointer">
|
||||
<span data-i18n="paywall_autorenew">Продлевать автоматически каждый месяц</span>
|
||||
</label>
|
||||
<div id="paywall-msg" style="font-size:12px;color:var(--text-secondary);margin-top:10px"></div>
|
||||
<button onclick="resetApp()" style="margin-top:8px;background:none;border:none;color:var(--text-secondary);font-size:13px;cursor:pointer;text-decoration:underline;font-family:inherit" data-i18n="paywall_cancel">Отмена</button>
|
||||
</div>
|
||||
@@ -1194,7 +1223,7 @@
|
||||
ru: {
|
||||
page_title: 'pdf2scan — онлайн сканирование PDF',
|
||||
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
|
||||
login_btn: 'Войти',
|
||||
login_btn: 'Войти / Регистрация',
|
||||
login_via_tg: 'Через Telegram',
|
||||
login_via_yandex: 'Через Яндекс',
|
||||
login_via_vk: 'Через VK',
|
||||
@@ -1309,6 +1338,19 @@
|
||||
paywall_pay_file: 'Оплатить файл',
|
||||
paywall_pay_sub: 'Подписка',
|
||||
paywall_month: 'мес',
|
||||
paywall_renew_title: 'Продлить подписку',
|
||||
paywall_renew_desc: 'Продлите доступ к большим файлам и пакетной обработке.',
|
||||
sub_active_title: 'Подписка активна',
|
||||
sub_until_label: 'до',
|
||||
sub_renew: 'Продлить',
|
||||
sub_perk_files: 'Большие файлы без лимита',
|
||||
sub_perk_batch: 'Пакетная обработка',
|
||||
sub_perk_priority: 'Приоритетная очередь',
|
||||
paywall_autorenew: 'Продлевать автоматически каждый месяц',
|
||||
sub_autorenew_on: 'Автопродление включено',
|
||||
sub_autorenew_off: 'Автопродление выключено',
|
||||
sub_autorenew_disable: 'Отключить',
|
||||
sub_autorenew_enable: 'Включить',
|
||||
paywall_cancel: 'Отмена',
|
||||
paywall_need_login: 'Для подписки войдите в аккаунт',
|
||||
seo_title: 'Как работает pdf2scan',
|
||||
@@ -1323,7 +1365,7 @@
|
||||
en: {
|
||||
page_title: 'pdf2scan — online PDF scan',
|
||||
logo_subtitle: 'Convert PDF to scanned document online',
|
||||
login_btn: 'Login',
|
||||
login_btn: 'Log in / Sign up',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
@@ -1439,6 +1481,19 @@
|
||||
paywall_pay_file: 'Pay for file',
|
||||
paywall_pay_sub: 'Subscription',
|
||||
paywall_month: 'mo',
|
||||
paywall_renew_title: 'Renew subscription',
|
||||
paywall_renew_desc: 'Extend access to large files and batch processing.',
|
||||
sub_active_title: 'Subscription active',
|
||||
sub_until_label: 'until',
|
||||
sub_renew: 'Renew',
|
||||
sub_perk_files: 'Large files, no limit',
|
||||
sub_perk_batch: 'Batch processing',
|
||||
sub_perk_priority: 'Priority queue',
|
||||
paywall_autorenew: 'Renew automatically every month',
|
||||
sub_autorenew_on: 'Auto-renewal on',
|
||||
sub_autorenew_off: 'Auto-renewal off',
|
||||
sub_autorenew_disable: 'Turn off',
|
||||
sub_autorenew_enable: 'Turn on',
|
||||
paywall_cancel: 'Cancel',
|
||||
paywall_need_login: 'Log in to subscribe',
|
||||
seo_title: 'How pdf2scan works',
|
||||
@@ -1453,7 +1508,7 @@
|
||||
de: {
|
||||
page_title: 'pdf2scan — Online-PDF-Scan',
|
||||
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
|
||||
login_btn: 'Anmelden',
|
||||
login_btn: 'Anmelden / Registrieren',
|
||||
login_via_tg: 'Über Telegram',
|
||||
login_via_yandex: 'Über Yandex',
|
||||
login_via_vk: 'Über VK',
|
||||
@@ -1569,6 +1624,19 @@
|
||||
paywall_pay_file: 'Datei bezahlen',
|
||||
paywall_pay_sub: 'Abo',
|
||||
paywall_month: 'Mon',
|
||||
paywall_renew_title: 'Abo verlängern',
|
||||
paywall_renew_desc: 'Verlängern Sie den Zugriff auf große Dateien und Stapelverarbeitung.',
|
||||
sub_active_title: 'Abo aktiv',
|
||||
sub_until_label: 'bis',
|
||||
sub_renew: 'Verlängern',
|
||||
sub_perk_files: 'Große Dateien ohne Limit',
|
||||
sub_perk_batch: 'Stapelverarbeitung',
|
||||
sub_perk_priority: 'Prioritäts-Warteschlange',
|
||||
paywall_autorenew: 'Automatisch monatlich verlängern',
|
||||
sub_autorenew_on: 'Automatische Verlängerung an',
|
||||
sub_autorenew_off: 'Automatische Verlängerung aus',
|
||||
sub_autorenew_disable: 'Ausschalten',
|
||||
sub_autorenew_enable: 'Einschalten',
|
||||
paywall_cancel: 'Abbrechen',
|
||||
paywall_need_login: 'Zum Abonnieren anmelden',
|
||||
seo_title: 'So funktioniert pdf2scan',
|
||||
@@ -1583,7 +1651,7 @@
|
||||
fr: {
|
||||
page_title: 'pdf2scan — scan PDF en ligne',
|
||||
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
|
||||
login_btn: 'Connexion',
|
||||
login_btn: 'Connexion / Inscription',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
@@ -1699,6 +1767,19 @@
|
||||
paywall_pay_file: 'Payer le fichier',
|
||||
paywall_pay_sub: 'Abonnement',
|
||||
paywall_month: 'mois',
|
||||
paywall_renew_title: 'Renouveler votre abonnement',
|
||||
paywall_renew_desc: 'Prolongez votre accès aux gros fichiers et au traitement par lots.',
|
||||
sub_active_title: 'Abonnement actif',
|
||||
sub_until_label: 'expire le',
|
||||
sub_renew: 'Renouveler',
|
||||
sub_perk_files: 'Gros fichiers sans limite',
|
||||
sub_perk_batch: 'Traitement par lots',
|
||||
sub_perk_priority: 'File prioritaire',
|
||||
paywall_autorenew: 'Renouveler automatiquement chaque mois',
|
||||
sub_autorenew_on: 'Renouvellement automatique activé',
|
||||
sub_autorenew_off: 'Renouvellement automatique désactivé',
|
||||
sub_autorenew_disable: 'Désactiver',
|
||||
sub_autorenew_enable: 'Activer',
|
||||
paywall_cancel: 'Annuler',
|
||||
paywall_need_login: 'Connectez-vous pour vous abonner',
|
||||
seo_title: 'Comment fonctionne pdf2scan',
|
||||
@@ -1713,7 +1794,7 @@
|
||||
it: {
|
||||
page_title: 'pdf2scan — scansione PDF online',
|
||||
logo_subtitle: 'Converti PDF in documenti scansionati online',
|
||||
login_btn: 'Accedi',
|
||||
login_btn: 'Accedi / Registrati',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
@@ -1829,6 +1910,19 @@
|
||||
paywall_pay_file: 'Paga file',
|
||||
paywall_pay_sub: 'Abbonamento',
|
||||
paywall_month: 'mese',
|
||||
paywall_renew_title: 'Rinnova abbonamento',
|
||||
paywall_renew_desc: 'Estendi accesso ai file grandi e alla elaborazione multipla.',
|
||||
sub_active_title: 'Abbonamento attivo',
|
||||
sub_until_label: 'fino al',
|
||||
sub_renew: 'Rinnova',
|
||||
sub_perk_files: 'File grandi senza limiti',
|
||||
sub_perk_batch: 'Elaborazione multipla',
|
||||
sub_perk_priority: 'Coda prioritaria',
|
||||
paywall_autorenew: 'Rinnova automaticamente ogni mese',
|
||||
sub_autorenew_on: 'Rinnovo automatico attivo',
|
||||
sub_autorenew_off: 'Rinnovo automatico disattivato',
|
||||
sub_autorenew_disable: 'Disattiva',
|
||||
sub_autorenew_enable: 'Attiva',
|
||||
paywall_cancel: 'Annulla',
|
||||
paywall_need_login: 'Accedi per abbonarti',
|
||||
seo_title: 'Come funziona pdf2scan',
|
||||
@@ -1843,7 +1937,7 @@
|
||||
es: {
|
||||
page_title: 'pdf2scan — escaneo PDF en línea',
|
||||
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
|
||||
login_btn: 'Iniciar sesión',
|
||||
login_btn: 'Entrar / Registro',
|
||||
login_via_tg: 'Con Telegram',
|
||||
login_via_yandex: 'Con Yandex',
|
||||
login_via_vk: 'Con VK',
|
||||
@@ -1959,6 +2053,19 @@
|
||||
paywall_pay_file: 'Pagar archivo',
|
||||
paywall_pay_sub: 'Suscripción',
|
||||
paywall_month: 'mes',
|
||||
paywall_renew_title: 'Renovar suscripción',
|
||||
paywall_renew_desc: 'Amplía el acceso a archivos grandes y al procesamiento por lotes.',
|
||||
sub_active_title: 'Suscripción activa',
|
||||
sub_until_label: 'hasta el',
|
||||
sub_renew: 'Renovar',
|
||||
sub_perk_files: 'Archivos grandes sin límite',
|
||||
sub_perk_batch: 'Procesamiento por lotes',
|
||||
sub_perk_priority: 'Cola prioritaria',
|
||||
paywall_autorenew: 'Renovar automáticamente cada mes',
|
||||
sub_autorenew_on: 'Renovación automática activada',
|
||||
sub_autorenew_off: 'Renovación automática desactivada',
|
||||
sub_autorenew_disable: 'Desactivar',
|
||||
sub_autorenew_enable: 'Activar',
|
||||
paywall_cancel: 'Cancelar',
|
||||
paywall_need_login: 'Inicia sesión para suscribirte',
|
||||
seo_title: 'Cómo funciona pdf2scan',
|
||||
@@ -1973,7 +2080,7 @@
|
||||
pt: {
|
||||
page_title: 'pdf2scan — digitalização PDF online',
|
||||
logo_subtitle: 'Converta PDF em documentos digitalizados online',
|
||||
login_btn: 'Entrar',
|
||||
login_btn: 'Entrar / Registrar',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
@@ -2089,6 +2196,19 @@
|
||||
paywall_pay_file: 'Pagar arquivo',
|
||||
paywall_pay_sub: 'Assinatura',
|
||||
paywall_month: 'mês',
|
||||
paywall_renew_title: 'Renovar assinatura',
|
||||
paywall_renew_desc: 'Amplie o acesso a arquivos grandes e ao processamento em lote.',
|
||||
sub_active_title: 'Assinatura ativa',
|
||||
sub_until_label: 'até',
|
||||
sub_renew: 'Renovar',
|
||||
sub_perk_files: 'Arquivos grandes sem limite',
|
||||
sub_perk_batch: 'Processamento em lote',
|
||||
sub_perk_priority: 'Fila prioritária',
|
||||
paywall_autorenew: 'Renovar automaticamente todo mês',
|
||||
sub_autorenew_on: 'Renovação automática ativada',
|
||||
sub_autorenew_off: 'Renovação automática desativada',
|
||||
sub_autorenew_disable: 'Desativar',
|
||||
sub_autorenew_enable: 'Ativar',
|
||||
paywall_cancel: 'Cancelar',
|
||||
paywall_need_login: 'Entre para assinar',
|
||||
seo_title: 'Como funciona o pdf2scan',
|
||||
@@ -2157,6 +2277,8 @@
|
||||
});
|
||||
// Re-render dynamic button state
|
||||
updateSubmitBtn();
|
||||
// Re-render subscription badge/card in the new language
|
||||
if (subUntilTs) renderSubStatus({ active: subUntilTs > (Date.now() / 1000), until: subUntilTs });
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
@@ -2241,14 +2363,16 @@
|
||||
paywallJob = jobId;
|
||||
paywallFilePrice = filePrice || 99;
|
||||
paywallSubPrice = subPrice || 299;
|
||||
const isBatch = paywallMode === 'batch';
|
||||
const subOnly = (paywallMode === 'batch' || paywallMode === 'renew');
|
||||
document.getElementById('paywall-file-price').textContent = paywallFilePrice;
|
||||
document.getElementById('paywall-sub-price').textContent = paywallSubPrice;
|
||||
document.getElementById('paywall-file-btn').style.display = isBatch ? 'none' : '';
|
||||
document.getElementById('paywall-file-btn').style.display = subOnly ? 'none' : '';
|
||||
const titleEl = document.querySelector('#paywall-card [data-i18n="paywall_title"]');
|
||||
const descEl = document.querySelector('#paywall-card [data-i18n="paywall_desc"]');
|
||||
if (titleEl) titleEl.textContent = t(isBatch ? 'paywall_batch_title' : 'paywall_title');
|
||||
if (descEl) descEl.textContent = t(isBatch ? 'paywall_batch_desc' : 'paywall_desc');
|
||||
const tKey = paywallMode === 'renew' ? 'paywall_renew_title' : (paywallMode === 'batch' ? 'paywall_batch_title' : 'paywall_title');
|
||||
const dKey = paywallMode === 'renew' ? 'paywall_renew_desc' : (paywallMode === 'batch' ? 'paywall_batch_desc' : 'paywall_desc');
|
||||
if (titleEl) titleEl.textContent = t(tKey);
|
||||
if (descEl) descEl.textContent = t(dKey);
|
||||
const emailEl = document.getElementById('paywall-email');
|
||||
if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email;
|
||||
document.getElementById('paywall-file-btn').disabled = false;
|
||||
@@ -2269,7 +2393,7 @@
|
||||
const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn');
|
||||
fb.disabled = true; sb.disabled = true;
|
||||
// Если файл ещё не загружен (пейволл показан ДО загрузки) — грузим сейчас
|
||||
if (!paywallJob && paywallMode !== 'batch' && selectedFile) {
|
||||
if (!paywallJob && paywallMode === 'file' && selectedFile) {
|
||||
msg.textContent = t('pay_uploading');
|
||||
try {
|
||||
const data = await uploadFileChunked(selectedFile, collectSettings(), null);
|
||||
@@ -2277,6 +2401,7 @@
|
||||
} catch (e) { msg.textContent = (e && e.message) ? e.message : t('err_network'); fb.disabled = false; sb.disabled = false; return; }
|
||||
}
|
||||
const body = { purpose, email };
|
||||
if (purpose === 'sub') { const ar = document.getElementById('paywall-autorenew'); body.autorenew = ar ? ar.checked : false; }
|
||||
if (paywallJob) body.job_id = paywallJob;
|
||||
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
|
||||
msg.textContent = t('pay_creating');
|
||||
@@ -2454,6 +2579,85 @@
|
||||
loginBtn.style.display = 'flex';
|
||||
userInfo.style.display = 'none';
|
||||
}
|
||||
loadSubscription(); // обновляем бейдж/карточку подписки под текущий вход
|
||||
}
|
||||
|
||||
// ── Статус подписки (бейдж в шапке + карточка на главной) ─────────────────
|
||||
let subUntilTs = 0, subAutorenew = false, subHasMethod = false;
|
||||
const _DATE_LOC = { ru: 'ru-RU', en: 'en-US', de: 'de-DE', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', pt: 'pt-PT' };
|
||||
function _subDate(ts, opts) {
|
||||
try { return new Date(ts * 1000).toLocaleDateString(_DATE_LOC[currentLang] || undefined, opts); }
|
||||
catch (_) { return new Date(ts * 1000).toLocaleDateString(); }
|
||||
}
|
||||
async function loadSubscription() {
|
||||
const badge = document.getElementById('sub-badge');
|
||||
const card = document.getElementById('sub-card');
|
||||
if (!tgUser && !sessionUser) { renderSubStatus({ active: false }); return; }
|
||||
try {
|
||||
const uid = tgUser ? (tgUser.user_id || tgUser.id || 0) : 0;
|
||||
const r = await fetch('/api/subscription' + (uid ? ('?user_id=' + encodeURIComponent(uid)) : ''));
|
||||
renderSubStatus(await r.json());
|
||||
} catch (_) { renderSubStatus({ active: false }); }
|
||||
}
|
||||
function renderSubStatus(d) {
|
||||
const badge = document.getElementById('sub-badge');
|
||||
const card = document.getElementById('sub-card');
|
||||
subUntilTs = (d && d.active) ? (d.until || 0) : 0;
|
||||
if (d && d.price) paywallSubPrice = d.price;
|
||||
if (d && d.active && d.until) {
|
||||
const shortD = _subDate(d.until, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const longD = _subDate(d.until, { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
if (badge) {
|
||||
document.getElementById('sub-badge-text').textContent = t('sub_until_label') + ' ' + shortD;
|
||||
badge.title = t('sub_active_title') + ' — ' + t('sub_until_label') + ' ' + longD;
|
||||
badge.style.display = 'inline-flex';
|
||||
}
|
||||
if (card) {
|
||||
document.getElementById('sub-card-until').textContent = t('sub_until_label') + ' ' + longD;
|
||||
card.style.display = 'block';
|
||||
}
|
||||
// автопродление
|
||||
subAutorenew = !!(d && d.autorenew);
|
||||
subHasMethod = !!(d && d.has_method);
|
||||
const arLabel = document.getElementById('sub-autorenew-label');
|
||||
const arToggle = document.getElementById('sub-autorenew-toggle');
|
||||
if (arLabel && arToggle) {
|
||||
if (subAutorenew) {
|
||||
arLabel.textContent = t('sub_autorenew_on');
|
||||
arToggle.textContent = t('sub_autorenew_disable');
|
||||
arToggle.style.display = 'inline';
|
||||
} else {
|
||||
arLabel.textContent = t('sub_autorenew_off');
|
||||
if (subHasMethod) { arToggle.textContent = t('sub_autorenew_enable'); arToggle.style.display = 'inline'; }
|
||||
else { arToggle.style.display = 'none'; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (badge) badge.style.display = 'none';
|
||||
if (card) card.style.display = 'none';
|
||||
}
|
||||
}
|
||||
async function toggleAutorenew() {
|
||||
if (!tgUser && !sessionUser) return;
|
||||
const body = { enable: !subAutorenew };
|
||||
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
|
||||
const arToggle = document.getElementById('sub-autorenew-toggle');
|
||||
if (arToggle) arToggle.style.pointerEvents = 'none';
|
||||
try {
|
||||
const r = await fetch('/api/subscription/autorenew', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
subAutorenew = !!d.autorenew;
|
||||
} catch (e) {}
|
||||
if (arToggle) arToggle.style.pointerEvents = '';
|
||||
loadSubscription();
|
||||
}
|
||||
function scrollToSubCard() {
|
||||
const card = document.getElementById('sub-card');
|
||||
if (card && card.style.display !== 'none') card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
function renewSub() {
|
||||
if (!tgUser && !sessionUser) return;
|
||||
showPaywall(null, paywallFilePrice, paywallSubPrice, 'renew');
|
||||
}
|
||||
|
||||
// ── Меню входа (Telegram / Яндекс) ────────────────────────────────────────
|
||||
@@ -2577,6 +2781,13 @@
|
||||
renderHeaderAuth();
|
||||
if (tgUser) loadHistory();
|
||||
if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK)
|
||||
// Маячок посещения (раз в сессию браузера) — считаем реальные заходы для админки
|
||||
try {
|
||||
if (!sessionStorage.getItem('p2s_hit')) {
|
||||
sessionStorage.setItem('p2s_hit', '1');
|
||||
fetch('/api/hit', { method: 'POST', keepalive: true }).catch(function () {});
|
||||
}
|
||||
} catch (_) {}
|
||||
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче
|
||||
(function () {
|
||||
const p = new URLSearchParams(location.search);
|
||||
|
||||
Reference in New Issue
Block a user