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
+
![]()
@@ -874,6 +878,26 @@
+
+
+
+
+ Вход по почте
+
+
+
+
+
+
+
+
Код отправлен на почту. Введите его:
+
+
+
+
+
+
+
@@ -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;