Админка + индикатор подписки + автопродление + де-брендинг входа

- /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:
berkio_admin_gitea
2026-07-03 17:10:50 +00:00
parent e25e7c888a
commit 655fce9198
2 changed files with 791 additions and 24 deletions

View File

@@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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():