Мобильная шапка + оплата подписки через Telegram (deep-link)

- Мобильная шапка: скрыт подзаголовок, короткая метка «Войти», бейдж ★ — не вылезает за край
- /subscribe?token=…: проверка HMAC-подписи из бота → сессия tg:<id> → авто-открытие оплаты
- Фронт: ?subscribe=1 открывает окно подписки после загрузки сессии

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
berkio_admin_gitea
2026-07-03 18:38:23 +00:00
parent 655fce9198
commit e44312305c
2 changed files with 76 additions and 4 deletions

View File

@@ -69,6 +69,9 @@ VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api
# ── Админ-панель ──────────────────────────────────────────────────────────────
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "")
# ── Подписка через Telegram-бота (подписанные deep-link) ──────────────────────
SUB_LINK_SECRET = os.getenv("SUB_LINK_SECRET", "") # общий секрет бота и веба
# ── Вход по почте (magic-code через SMTP) ─────────────────────────────────────
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
@@ -2131,6 +2134,38 @@ async def yandex_callback(request: Request, code: str = "", state: str = ""):
return resp
def _verify_sub_token(token: str):
"""Проверяет подписанный deep-link из бота: '<tgid>.<exp>.<sig>'. Возвращает tgid (int) или None."""
if not SUB_LINK_SECRET or not token:
return None
try:
tgid_s, exp_s, sig = token.split(".", 2)
if float(exp_s) < time.time():
return None
expect = hmac.new(SUB_LINK_SECRET.encode(), f"{tgid_s}.{exp_s}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expect):
return None
return int(tgid_s)
except Exception:
return None
@app.get("/subscribe")
async def subscribe_via_bot(request: Request, token: str = "", name: str = ""):
"""Deep-link из Telegram-бота: проверяем подпись, логиним как tg:<id> (сессия),
ведём на главную с авто-открытием окна оплаты подписки."""
tgid = _verify_sub_token(token)
if not tgid:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error", status_code=302)
account_id = f"tg:{tgid}"
_db_ensure_account(account_id)
sess = _db_create_session(account_id, "telegram", (name or "").strip()[:64] or "Telegram", "")
resp = RedirectResponse(f"{SITE_BASE_URL}/?subscribe=1", status_code=302)
resp.set_cookie(SESSION_COOKIE, sess, max_age=SESSION_DAYS * 86400,
httponly=True, secure=True, samesite="lax", path="/")
return resp
@app.get("/api/me")
async def me(request: Request):
sess = _db_get_session(request.cookies.get(SESSION_COOKIE))

View File

@@ -241,9 +241,12 @@
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
#tg-login-header-btn:hover { background: var(--blue-hover); }
/* Короткий вариант метки кнопки входа — только на узких экранах */
.login-mini { display: none; }
/* ── Main card ── */
main {
@@ -746,6 +749,21 @@
.bs { font-size: 12px; padding: 3px 8px; }
}
/* ── Компактная шапка на телефонах ── */
@media (max-width: 500px) {
header { padding: 8px 12px; gap: 8px; flex-wrap: wrap; }
.logo-subtitle { display: none; } /* длинный подзаголовок распирает шапку — прячем на телефоне */
.logo-text { font-size: 18px; }
#header-auth { gap: 8px; }
#tg-login-header-btn { padding: 7px 12px; font-size: 12.5px; }
.login-full { display: none; }
.login-mini { display: inline; }
#tg-user-info { font-size: 13px; gap: 6px; }
#tg-user-name { display: inline-block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
#sub-badge { padding: 4px 8px; }
#sub-badge-text { display: none; } /* дата — только в карточке ниже; в шапке оставляем ★ */
}
/* ── SEO section ── */
#seo-section {
width: 100%;
@@ -851,7 +869,8 @@
<div id="header-auth" style="position:relative;">
<button id="tg-login-header-btn" onclick="toggleLoginMenu(event)" style="display:none;">
<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>
<span class="login-full" data-i18n="login_btn">Войти / Регистрация</span>
<span class="login-mini" data-i18n="login_btn_mini">Войти</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;">
@@ -1224,6 +1243,7 @@
page_title: 'pdf2scan — онлайн сканирование PDF',
logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов',
login_btn: 'Войти / Регистрация',
login_btn_mini: 'Войти',
login_via_tg: 'Через Telegram',
login_via_yandex: 'Через Яндекс',
login_via_vk: 'Через VK',
@@ -1366,6 +1386,7 @@
page_title: 'pdf2scan — online PDF scan',
logo_subtitle: 'Convert PDF to scanned document online',
login_btn: 'Log in / Sign up',
login_btn_mini: 'Log in',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
@@ -1509,6 +1530,7 @@
page_title: 'pdf2scan — Online-PDF-Scan',
logo_subtitle: 'PDF online in gescannte Dokumente umwandeln',
login_btn: 'Anmelden / Registrieren',
login_btn_mini: 'Anmelden',
login_via_tg: 'Über Telegram',
login_via_yandex: 'Über Yandex',
login_via_vk: 'Über VK',
@@ -1652,6 +1674,7 @@
page_title: 'pdf2scan — scan PDF en ligne',
logo_subtitle: 'Convertir des PDF en documents scannés en ligne',
login_btn: 'Connexion / Inscription',
login_btn_mini: 'Connexion',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
@@ -1795,6 +1818,7 @@
page_title: 'pdf2scan — scansione PDF online',
logo_subtitle: 'Converti PDF in documenti scansionati online',
login_btn: 'Accedi / Registrati',
login_btn_mini: 'Accedi',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
@@ -1938,6 +1962,7 @@
page_title: 'pdf2scan — escaneo PDF en línea',
logo_subtitle: 'Convierte PDF en documentos escaneados en línea',
login_btn: 'Entrar / Registro',
login_btn_mini: 'Entrar',
login_via_tg: 'Con Telegram',
login_via_yandex: 'Con Yandex',
login_via_vk: 'Con VK',
@@ -2081,6 +2106,7 @@
page_title: 'pdf2scan — digitalização PDF online',
logo_subtitle: 'Converta PDF em documentos digitalizados online',
login_btn: 'Entrar / Registrar',
login_btn_mini: 'Entrar',
login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
@@ -2590,13 +2616,18 @@
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());
const d = await r.json();
renderSubStatus(d);
// Пришли по ссылке подписки из бота — открываем оплату (если ещё не подписан)
if (window._openSubAfterLoad) {
window._openSubAfterLoad = false;
if (!d.active) showPaywall(null, paywallFilePrice, (d.price || paywallSubPrice), 'renew');
else scrollToSubCard();
}
} catch (_) { renderSubStatus({ active: false }); }
}
function renderSubStatus(d) {
@@ -2796,6 +2827,12 @@
try { history.replaceState({}, '', location.pathname); } catch (_) {}
if (v === 'error') showError(t('err_login'));
}
// Приход по ссылке подписки из Telegram-бота (/subscribe → /?subscribe=1):
// после загрузки сессии авто-открываем окно оплаты подписки.
if (p.has('subscribe')) {
window._openSubAfterLoad = true;
try { history.replaceState({}, '', location.pathname); } catch (_) {}
}
})();
// ── Drop zone ─────────────────────────────────────────────────────────────