From b0d2580c37a1e951abf1fc438ea4d469e2a03f60 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 09:08:08 +0000 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=87=D1=82=D0=B5=20(magic-code=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20SMTP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/auth/email/{start,verify}: 6-значный код на email (TTL 10мин, лимит 1/мин, до 5 попыток), аккаунт email: + cookie-сессия - _send_email (smtplib SSL/STARTTLS), таблица WEB_EMAIL_CODES - Фронт: пункт «Через почту» в меню + модалка (email → код → вход), i18n Co-Authored-By: Claude Opus 4.8 --- app/main.py | 111 ++++++++++++++++++++++++++++++++++++++ app/static/index.html | 120 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) diff --git a/app/main.py b/app/main.py index befcd91..b9483c1 100644 --- a/app/main.py +++ b/app/main.py @@ -66,6 +66,13 @@ 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" +# ── Вход по почте (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 МБ) UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы @@ -280,6 +287,15 @@ def _db_ensure_auth_table(): 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. con.execute(""" CREATE TABLE IF NOT EXISTS WEB_PAYMENTS ( @@ -1996,6 +2012,101 @@ async def vk_callback(request: Request, code: str = "", state: str = "", device_ 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 = """ diff --git a/app/static/index.html b/app/static/index.html index bb11cb4..e1891f4 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -865,6 +865,10 @@ VK Через VK + + + +
@@ -1163,6 +1187,14 @@ login_via_tg: 'Через Telegram', login_via_yandex: 'Через Яндекс', 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: 'Не удалось войти. Попробуйте снова.', logout_btn: 'Выйти', page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.', @@ -1277,6 +1309,14 @@ login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', 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.', logout_btn: 'Logout', 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_yandex: 'Über Yandex', 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.', logout_btn: 'Abmelden', 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_yandex: 'Via Yandex', 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.', 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.', @@ -1622,6 +1678,14 @@ login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', 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.', logout_btn: 'Esci', 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_yandex: 'Con Yandex', 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.', 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.', @@ -1852,6 +1924,14 @@ login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', 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.', 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.', @@ -2305,6 +2385,46 @@ 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 _authToken = null;