Вход по почте (magic-code через SMTP)

- /api/auth/email/{start,verify}: 6-значный код на email (TTL 10мин, лимит
  1/мин, до 5 попыток), аккаунт email:<addr> + cookie-сессия
- _send_email (smtplib SSL/STARTTLS), таблица WEB_EMAIL_CODES
- Фронт: пункт «Через почту» в меню + модалка (email → код → вход), i18n

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 09:08:08 +00:00
parent 836784e56f
commit b0d2580c37
2 changed files with 231 additions and 0 deletions

View File

@@ -66,6 +66,13 @@ SESSION_DAYS = 30
VK_CLIENT_ID = os.getenv("VK_CLIENT_ID", "") VK_CLIENT_ID = os.getenv("VK_CLIENT_ID", "")
VK_CLIENT_SECRET = os.getenv("VK_CLIENT_SECRET", "") VK_CLIENT_SECRET = os.getenv("VK_CLIENT_SECRET", "")
VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/vk/callback" VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/vk/callback"
# ── Вход по почте (magic-code через SMTP) ─────────────────────────────────────
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
SMTP_FROM = os.getenv("SMTP_FROM", "") or SMTP_USER
EMAIL_CODE_TTL = 600 # 10 минут на ввод кода
# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ) # Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
@@ -280,6 +287,15 @@ def _db_ensure_auth_table():
expires_at REAL expires_at REAL
) )
""") """)
# Коды для входа по почте (magic-code).
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_EMAIL_CODES (
email TEXT PRIMARY KEY,
code TEXT,
created_at REAL,
attempts INTEGER DEFAULT 0
)
""")
# Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub. # Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub.
con.execute(""" con.execute("""
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS ( CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
@@ -1996,6 +2012,101 @@ async def vk_callback(request: Request, code: str = "", state: str = "", device_
return resp return resp
# ── Вход по почте (magic-code) ────────────────────────────────────────────────
import smtplib as _smtplib
import ssl as _ssl
from email.mime.text import MIMEText as _MIMEText
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_email_rate: dict = {} # email → ts последней отправки (rate-limit)
def _send_email(to: str, subject: str, body: str):
if not (SMTP_HOST and SMTP_USER and SMTP_PASS):
raise RuntimeError("SMTP не настроен")
msg = _MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to
ctx = _ssl.create_default_context()
if SMTP_PORT == 465:
with _smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx, timeout=20) as s:
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg.as_string())
else:
with _smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.starttls(context=ctx)
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg.as_string())
@app.post("/api/auth/email/start")
async def email_start(payload: dict):
if not (SMTP_HOST and SMTP_USER):
raise HTTPException(status_code=503, detail="Вход по почте не настроен.")
email = (payload.get("email") or "").strip().lower()[:128] if isinstance(payload, dict) else ""
if not _EMAIL_RE.match(email):
raise HTTPException(status_code=400, detail="Некорректный email")
now = time.time()
if now - _email_rate.get(email, 0) < 60:
raise HTTPException(status_code=429, detail="Код уже отправлен, подождите минуту.")
code = f"{secrets.randbelow(1000000):06d}"
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute(
"INSERT INTO WEB_EMAIL_CODES (email, code, created_at, attempts) VALUES (?,?,?,0) "
"ON CONFLICT(email) DO UPDATE SET code=excluded.code, created_at=excluded.created_at, attempts=0",
(email, code, now))
con.commit()
except Exception:
raise HTTPException(status_code=500, detail="Ошибка БД")
try:
await asyncio.to_thread(
_send_email, email, "Код входа pdf2scan",
f"Ваш код для входа на pdf2scan.online: {code}\n\nКод действует 10 минут.\nЕсли вы не запрашивали вход — проигнорируйте письмо.")
except Exception as e:
print(f"email send error: {e}")
raise HTTPException(status_code=502, detail="Не удалось отправить письмо. Попробуйте позже.")
_email_rate[email] = now
return {"ok": True}
@app.post("/api/auth/email/verify")
async def email_verify(payload: dict):
email = (payload.get("email") or "").strip().lower()[:128] if isinstance(payload, dict) else ""
code = (payload.get("code") or "").strip()[:6]
if not _EMAIL_RE.match(email) or not code.isdigit():
raise HTTPException(status_code=400, detail="Некорректные данные")
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
cur = con.execute("SELECT code, created_at, attempts FROM WEB_EMAIL_CODES WHERE email=?", (email,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Код не найден. Запросите новый.")
saved, created, attempts = row
if time.time() - (created or 0) > EMAIL_CODE_TTL:
raise HTTPException(status_code=400, detail="Код истёк. Запросите новый.")
if (attempts or 0) >= 5:
raise HTTPException(status_code=429, detail="Слишком много попыток. Запросите новый код.")
if code != saved:
con.execute("UPDATE WEB_EMAIL_CODES SET attempts=attempts+1 WHERE email=?", (email,))
con.commit()
raise HTTPException(status_code=400, detail="Неверный код")
con.execute("DELETE FROM WEB_EMAIL_CODES WHERE email=?", (email,))
con.commit()
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Ошибка БД")
account_id = f"email:{email}"
_db_ensure_account(account_id)
token = _db_create_session(account_id, "email", email.split("@")[0], email)
resp = JSONResponse({"ok": True})
resp.set_cookie(SESSION_COOKIE, token, max_age=SESSION_DAYS * 86400,
httponly=True, secure=True, samesite="lax", path="/")
return resp
_API_DOCS_HTML = """<!DOCTYPE html> _API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@@ -865,6 +865,10 @@
<span style="display:inline-flex;width:22px;height:18px;background:#07f;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:11px;">VK</span> <span style="display:inline-flex;width:22px;height:18px;background:#07f;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:11px;">VK</span>
<span data-i18n="login_via_vk">Через VK</span> <span data-i18n="login_via_vk">Через VK</span>
</button> </button>
<button onclick="closeLoginMenu();openEmailLogin();" 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;">
<span style="display:inline-flex;width:22px;height:18px;background:#64748b;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;"></span>
<span data-i18n="login_via_email">Через почту</span>
</button>
</div> </div>
<div id="tg-user-info" style="display:none;"> <div id="tg-user-info" style="display:none;">
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'"> <img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
@@ -874,6 +878,26 @@
</div> </div>
</header> </header>
<!-- Модалка входа по почте -->
<div id="email-login-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100;align-items:center;justify-content:center;padding:16px;">
<div style="background:#fff;border-radius:14px;max-width:340px;width:100%;padding:22px;box-shadow:0 10px 40px rgba(0,0,0,.25);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<strong style="font-size:17px;color:#1c2b45" data-i18n="email_login_title">Вход по почте</strong>
<button onclick="closeEmailLogin()" style="background:none;border:none;font-size:22px;line-height:1;cursor:pointer;color:#94a3b8">&times;</button>
</div>
<div id="email-step-1">
<input type="email" id="email-login-input" data-i18n-ph="email_ph" placeholder="ваш email" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:15px;font-family:inherit;margin-bottom:10px">
<button id="email-send-btn" onclick="emailSendCode()" style="width:100%;padding:11px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_send_code">Получить код</button>
</div>
<div id="email-step-2" style="display:none">
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 10px" data-i18n="email_code_sent">Код отправлен на почту. Введите его:</p>
<input type="text" id="email-code-input" inputmode="numeric" maxlength="6" placeholder="000000" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:20px;letter-spacing:6px;text-align:center;font-family:inherit;margin-bottom:10px">
<button id="email-verify-btn" onclick="emailVerifyCode()" style="width:100%;padding:11px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_login_btn">Войти</button>
</div>
<div id="email-login-msg" style="font-size:12px;color:#e05555;margin-top:10px;min-height:16px"></div>
</div>
</div>
<main> <main>
<!-- Description --> <!-- Description -->
@@ -1163,6 +1187,14 @@
login_via_tg: 'Через Telegram', login_via_tg: 'Через Telegram',
login_via_yandex: 'Через Яндекс', login_via_yandex: 'Через Яндекс',
login_via_vk: 'Через VK', login_via_vk: 'Через VK',
login_via_email: 'Через почту',
email_login_title: 'Вход по почте',
email_ph: 'ваш email',
email_send_code: 'Получить код',
email_code_sent: 'Код отправлен на почту. Введите его:',
email_login_btn: 'Войти',
email_sending: 'Отправляем код…',
email_bad_code: 'Неверный код',
err_login: 'Не удалось войти. Попробуйте снова.', err_login: 'Не удалось войти. Попробуйте снова.',
logout_btn: 'Выйти', logout_btn: 'Выйти',
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.', page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
@@ -1277,6 +1309,14 @@
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK', login_via_vk: 'Via VK',
login_via_email: 'Via email',
email_login_title: 'Email login',
email_ph: 'your email',
email_send_code: 'Get code',
email_code_sent: 'Code sent to your email. Enter it:',
email_login_btn: 'Log in',
email_sending: 'Sending code…',
email_bad_code: 'Invalid code',
err_login: 'Login failed. Please try again.', err_login: 'Login failed. Please try again.',
logout_btn: 'Logout', logout_btn: 'Logout',
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.', page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
@@ -1392,6 +1432,14 @@
login_via_tg: 'Über Telegram', login_via_tg: 'Über Telegram',
login_via_yandex: 'Über Yandex', login_via_yandex: 'Über Yandex',
login_via_vk: 'Über VK', login_via_vk: 'Über VK',
login_via_email: 'Per E-Mail',
email_login_title: 'E-Mail-Anmeldung',
email_ph: 'Ihre E-Mail',
email_send_code: 'Code erhalten',
email_code_sent: 'Code an Ihre E-Mail gesendet. Eingeben:',
email_login_btn: 'Anmelden',
email_sending: 'Code wird gesendet…',
email_bad_code: 'Ungültiger Code',
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.', err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
logout_btn: 'Abmelden', logout_btn: 'Abmelden',
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.', page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
@@ -1507,6 +1555,14 @@
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK', login_via_vk: 'Via VK',
login_via_email: 'Par e-mail',
email_login_title: 'Connexion par e-mail',
email_ph: 'votre e-mail',
email_send_code: 'Recevoir le code',
email_code_sent: 'Code envoyé par e-mail. Saisissez-le :',
email_login_btn: 'Se connecter',
email_sending: 'Envoi du code…',
email_bad_code: 'Code invalide',
err_login: 'Échec de connexion. Réessayez.', err_login: 'Échec de connexion. Réessayez.',
logout_btn: 'Déconnexion', logout_btn: 'Déconnexion',
page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.', page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.',
@@ -1622,6 +1678,14 @@
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK', login_via_vk: 'Via VK',
login_via_email: 'Via email',
email_login_title: 'Accesso via email',
email_ph: 'la tua email',
email_send_code: 'Ricevi codice',
email_code_sent: 'Codice inviato via email. Inseriscilo:',
email_login_btn: 'Accedi',
email_sending: 'Invio codice…',
email_bad_code: 'Codice non valido',
err_login: 'Accesso non riuscito. Riprova.', err_login: 'Accesso non riuscito. Riprova.',
logout_btn: 'Esci', logout_btn: 'Esci',
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.', page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
@@ -1737,6 +1801,14 @@
login_via_tg: 'Con Telegram', login_via_tg: 'Con Telegram',
login_via_yandex: 'Con Yandex', login_via_yandex: 'Con Yandex',
login_via_vk: 'Con VK', login_via_vk: 'Con VK',
login_via_email: 'Por correo',
email_login_title: 'Acceso por correo',
email_ph: 'tu correo',
email_send_code: 'Obtener código',
email_code_sent: 'Código enviado a tu correo. Ingrésalo:',
email_login_btn: 'Entrar',
email_sending: 'Enviando código…',
email_bad_code: 'Código inválido',
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.', err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
logout_btn: 'Cerrar sesión', logout_btn: 'Cerrar sesión',
page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.', page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
@@ -1852,6 +1924,14 @@
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK', login_via_vk: 'Via VK',
login_via_email: 'Por e-mail',
email_login_title: 'Entrar por e-mail',
email_ph: 'seu e-mail',
email_send_code: 'Receber código',
email_code_sent: 'Código enviado ao seu e-mail. Digite:',
email_login_btn: 'Entrar',
email_sending: 'Enviando código…',
email_bad_code: 'Código inválido',
err_login: 'Falha no login. Tente novamente.', err_login: 'Falha no login. Tente novamente.',
logout_btn: 'Sair', logout_btn: 'Sair',
page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.', page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.',
@@ -2305,6 +2385,46 @@
if (sessionUser) loadHistory(); if (sessionUser) loadHistory();
} }
// ── Вход по почте (код) ───────────────────────────────────────────────────
let _emailForCode = '';
function openEmailLogin() {
document.getElementById('email-step-1').style.display = 'block';
document.getElementById('email-step-2').style.display = 'none';
document.getElementById('email-login-msg').textContent = '';
document.getElementById('email-login-input').value = '';
document.getElementById('email-code-input').value = '';
document.getElementById('email-login-overlay').style.display = 'flex';
}
function closeEmailLogin() { document.getElementById('email-login-overlay').style.display = 'none'; }
async function emailSendCode() {
const email = (document.getElementById('email-login-input').value || '').trim();
const msg = document.getElementById('email-login-msg');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
const btn = document.getElementById('email-send-btn'); btn.disabled = true; msg.textContent = t('email_sending');
try {
const r = await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
const d = await r.json();
if (r.ok) {
_emailForCode = email;
document.getElementById('email-step-1').style.display = 'none';
document.getElementById('email-step-2').style.display = 'block';
msg.textContent = ''; document.getElementById('email-code-input').focus();
} else { msg.textContent = d.detail || t('err_login'); btn.disabled = false; }
} catch (e) { msg.textContent = t('err_login'); btn.disabled = false; }
}
async function emailVerifyCode() {
const code = (document.getElementById('email-code-input').value || '').trim();
const msg = document.getElementById('email-login-msg');
if (!/^\d{6}$/.test(code)) { msg.textContent = t('email_bad_code'); return; }
const btn = document.getElementById('email-verify-btn'); btn.disabled = true; msg.textContent = '';
try {
const r = await fetch('/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: _emailForCode, code }) });
const d = await r.json();
if (r.ok) { closeEmailLogin(); loadMe(); }
else { msg.textContent = d.detail || t('email_bad_code'); btn.disabled = false; }
} catch (e) { msg.textContent = t('err_login'); btn.disabled = false; }
}
// ── Авторизация через бота ──────────────────────────────────────────────── // ── Авторизация через бота ────────────────────────────────────────────────
let _authPollInterval = null; let _authPollInterval = null;
let _authToken = null; let _authToken = null;