Пейвол больших файлов из бота: /pay deep-link + освобождение по подписке

- /pay?tg&job&exp&sig: HMAC-проверка → сессия tg:<id> → /?pay_job=<id> → пейволл по заданию
- /api/held/release: освобождение придержанного файла владельцу с активной подпиской
- Фронт: ?pay_job открывает оплату файла (99₽/подписка); при активной подписке — освобождает бесплатно

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
berkio_admin_gitea
2026-07-04 08:16:18 +00:00
parent e44312305c
commit 68b4648a06
2 changed files with 89 additions and 0 deletions

View File

@@ -2166,6 +2166,66 @@ async def subscribe_via_bot(request: Request, token: str = "", name: str = ""):
return resp 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") @app.get("/api/me")
async def me(request: Request): async def me(request: Request):
sess = _db_get_session(request.cookies.get(SESSION_COOKIE)) sess = _db_get_session(request.cookies.get(SESSION_COOKIE))

View File

@@ -1371,6 +1371,7 @@
sub_autorenew_off: 'Автопродление выключено', sub_autorenew_off: 'Автопродление выключено',
sub_autorenew_disable: 'Отключить', sub_autorenew_disable: 'Отключить',
sub_autorenew_enable: 'Включить', sub_autorenew_enable: 'Включить',
bot_file_released: 'Файл освобождён — обработка началась. Результат придёт в бот.',
paywall_cancel: 'Отмена', paywall_cancel: 'Отмена',
paywall_need_login: 'Для подписки войдите в аккаунт', paywall_need_login: 'Для подписки войдите в аккаунт',
seo_title: 'Как работает pdf2scan', seo_title: 'Как работает pdf2scan',
@@ -1515,6 +1516,7 @@
sub_autorenew_off: 'Auto-renewal off', sub_autorenew_off: 'Auto-renewal off',
sub_autorenew_disable: 'Turn off', sub_autorenew_disable: 'Turn off',
sub_autorenew_enable: 'Turn on', sub_autorenew_enable: 'Turn on',
bot_file_released: 'File released — processing started. The result will arrive in the bot.',
paywall_cancel: 'Cancel', paywall_cancel: 'Cancel',
paywall_need_login: 'Log in to subscribe', paywall_need_login: 'Log in to subscribe',
seo_title: 'How pdf2scan works', seo_title: 'How pdf2scan works',
@@ -1659,6 +1661,7 @@
sub_autorenew_off: 'Automatische Verlängerung aus', sub_autorenew_off: 'Automatische Verlängerung aus',
sub_autorenew_disable: 'Ausschalten', sub_autorenew_disable: 'Ausschalten',
sub_autorenew_enable: 'Einschalten', sub_autorenew_enable: 'Einschalten',
bot_file_released: 'Datei freigegeben — Verarbeitung gestartet. Das Ergebnis kommt in den Bot.',
paywall_cancel: 'Abbrechen', paywall_cancel: 'Abbrechen',
paywall_need_login: 'Zum Abonnieren anmelden', paywall_need_login: 'Zum Abonnieren anmelden',
seo_title: 'So funktioniert pdf2scan', seo_title: 'So funktioniert pdf2scan',
@@ -1803,6 +1806,7 @@
sub_autorenew_off: 'Renouvellement automatique désactivé', sub_autorenew_off: 'Renouvellement automatique désactivé',
sub_autorenew_disable: 'Désactiver', sub_autorenew_disable: 'Désactiver',
sub_autorenew_enable: 'Activer', sub_autorenew_enable: 'Activer',
bot_file_released: 'Fichier débloqué — traitement lancé. Le résultat arrivera dans le bot.',
paywall_cancel: 'Annuler', paywall_cancel: 'Annuler',
paywall_need_login: 'Connectez-vous pour vous abonner', paywall_need_login: 'Connectez-vous pour vous abonner',
seo_title: 'Comment fonctionne pdf2scan', seo_title: 'Comment fonctionne pdf2scan',
@@ -1947,6 +1951,7 @@
sub_autorenew_off: 'Rinnovo automatico disattivato', sub_autorenew_off: 'Rinnovo automatico disattivato',
sub_autorenew_disable: 'Disattiva', sub_autorenew_disable: 'Disattiva',
sub_autorenew_enable: 'Attiva', sub_autorenew_enable: 'Attiva',
bot_file_released: 'File sbloccato — elaborazione avviata. Il risultato arriverà nel bot.',
paywall_cancel: 'Annulla', paywall_cancel: 'Annulla',
paywall_need_login: 'Accedi per abbonarti', paywall_need_login: 'Accedi per abbonarti',
seo_title: 'Come funziona pdf2scan', seo_title: 'Come funziona pdf2scan',
@@ -2091,6 +2096,7 @@
sub_autorenew_off: 'Renovación automática desactivada', sub_autorenew_off: 'Renovación automática desactivada',
sub_autorenew_disable: 'Desactivar', sub_autorenew_disable: 'Desactivar',
sub_autorenew_enable: 'Activar', sub_autorenew_enable: 'Activar',
bot_file_released: 'Archivo liberado — procesamiento iniciado. El resultado llegará al bot.',
paywall_cancel: 'Cancelar', paywall_cancel: 'Cancelar',
paywall_need_login: 'Inicia sesión para suscribirte', paywall_need_login: 'Inicia sesión para suscribirte',
seo_title: 'Cómo funciona pdf2scan', seo_title: 'Cómo funciona pdf2scan',
@@ -2235,6 +2241,7 @@
sub_autorenew_off: 'Renovação automática desativada', sub_autorenew_off: 'Renovação automática desativada',
sub_autorenew_disable: 'Desativar', sub_autorenew_disable: 'Desativar',
sub_autorenew_enable: 'Ativar', sub_autorenew_enable: 'Ativar',
bot_file_released: 'Arquivo liberado — processamento iniciado. O resultado chegará no bot.',
paywall_cancel: 'Cancelar', paywall_cancel: 'Cancelar',
paywall_need_login: 'Entre para assinar', paywall_need_login: 'Entre para assinar',
seo_title: 'Como funciona o pdf2scan', seo_title: 'Como funciona o pdf2scan',
@@ -2628,6 +2635,22 @@
if (!d.active) showPaywall(null, paywallFilePrice, (d.price || paywallSubPrice), 'renew'); if (!d.active) showPaywall(null, paywallFilePrice, (d.price || paywallSubPrice), 'renew');
else scrollToSubCard(); else scrollToSubCard();
} }
// Пришли по ссылке оплаты файла из бота — открываем пейволл для этого задания
if (window._payJobAfterLoad) {
const job = window._payJobAfterLoad;
window._payJobAfterLoad = null;
if (d.active) {
// уже есть подписка — освобождаем файл бесплатно, без повторной оплаты
const body = { job_id: job };
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
try {
await fetch('/api/held/release', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
showPaySuccess(t('bot_file_released'));
} catch (_) {}
} else {
showPaywall(job, paywallFilePrice, (d.price || paywallSubPrice), 'file');
}
}
} catch (_) { renderSubStatus({ active: false }); } } catch (_) { renderSubStatus({ active: false }); }
} }
function renderSubStatus(d) { function renderSubStatus(d) {
@@ -2833,6 +2856,12 @@
window._openSubAfterLoad = true; window._openSubAfterLoad = true;
try { history.replaceState({}, '', location.pathname); } catch (_) {} try { history.replaceState({}, '', location.pathname); } catch (_) {}
} }
// Приход по ссылке оплаты файла из бота (/pay → /?pay_job=<id>):
// после загрузки сессии открываем пейволл для этого задания.
if (p.has('pay_job')) {
window._payJobAfterLoad = p.get('pay_job');
try { history.replaceState({}, '', location.pathname); } catch (_) {}
}
})(); })();
// ── Drop zone ───────────────────────────────────────────────────────────── // ── Drop zone ─────────────────────────────────────────────────────────────