From c1cf1ddd2f6f259d958ba5a28ada03ee9c04e0bd Mon Sep 17 00:00:00 2001 From: jze9 Date: Thu, 23 Jul 2026 18:37:22 +0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20email=20+=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20SMTP/Ollama=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Резенд письма верификации (/auth/resend-verification), модалка на фронте с поллингом статуса, страница /verify-email/:token - SMTP переведён на собственный Postfix (mail.jze9mail.ru, STARTTLS, SMTP_TLS_VERIFY) вместо Yandex-заглушки в дефолтах и .env.example - OLLAMA_URL и модель в worker-gpu синхронизированы с новым GPU-хостом (llama3:8b -> qwen2.5:7b, которой раньше не было на сервере) Co-Authored-By: Claude Sonnet 5 --- .env.example | 11 +- services/api/app/api/auth.py | 31 +++++ services/api/app/config.py | 9 +- services/frontend/src/api/client.ts | 3 + .../src/components/EmailVerificationModal.tsx | 126 ++++++++++++++++++ services/frontend/src/components/Layout.tsx | 3 + services/frontend/src/main.tsx | 2 + services/frontend/src/pages/VerifyEmail.tsx | 83 ++++++++++++ services/worker-gpu/app/ollama_client.py | 2 +- services/worker-notifier/app/config.py | 9 +- services/worker-notifier/app/email_sender.py | 20 ++- 11 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 services/frontend/src/components/EmailVerificationModal.tsx create mode 100644 services/frontend/src/pages/VerifyEmail.tsx diff --git a/.env.example b/.env.example index f8aaa7e..fcdc8f3 100644 --- a/.env.example +++ b/.env.example @@ -28,12 +28,13 @@ OLLAMA_URL=http://ollama:11434 SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32 ACCESS_TOKEN_EXPIRE_MINUTES=10080 -# SMTP (Yandex) -SMTP_HOST=smtp.yandex.ru -SMTP_PORT=465 -SMTP_USER=noreply@jze9.ru +# SMTP (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS) +SMTP_HOST=mail.jze9mail.ru +SMTP_PORT=587 +SMTP_USER=noreply SMTP_PASSWORD=changeme -SMTP_FROM=noreply@jze9.ru +SMTP_FROM=noreply@jze9mail.ru +SMTP_TLS_VERIFY=true # App APP_URL=https://academic.jze9.ru diff --git a/services/api/app/api/auth.py b/services/api/app/api/auth.py index 099e353..afd06ca 100644 --- a/services/api/app/api/auth.py +++ b/services/api/app/api/auth.py @@ -98,6 +98,37 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken: return UserInToken.model_validate(current_user) +@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT) +async def resend_verification( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Повторно отправить письмо с подтверждением email.""" + if current_user.is_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email уже подтверждён", + ) + + if not current_user.verification_token: + current_user.verification_token = secrets.token_urlsafe(32) + await db.commit() + await db.refresh(current_user) + + try: + celery_app.send_task( + "notify.send_verification", + args=[current_user.email, current_user.name, current_user.verification_token], + queue="queue.notify", + ) + except Exception as e: + logger.warning(f"Не удалось поставить задачу повторной верификации: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Не удалось отправить письмо, попробуйте позже", + ) + + @router.post("/verify-email/{token}", status_code=status.HTTP_200_OK) async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict: """Подтвердить email по токену из письма.""" diff --git a/services/api/app/config.py b/services/api/app/config.py index 3698458..0cd313f 100644 --- a/services/api/app/config.py +++ b/services/api/app/config.py @@ -45,11 +45,12 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней # SMTP - SMTP_HOST: str = "smtp.yandex.ru" - SMTP_PORT: int = 465 - SMTP_USER: str = "noreply@jze9.ru" + SMTP_HOST: str = "mail.jze9mail.ru" + SMTP_PORT: int = 587 + SMTP_USER: str = "noreply" SMTP_PASSWORD: str = "changeme" - SMTP_FROM: str = "noreply@jze9.ru" + SMTP_FROM: str = "noreply@jze9mail.ru" + SMTP_TLS_VERIFY: bool = True # App APP_URL: str = "https://academic.jze9.ru" diff --git a/services/frontend/src/api/client.ts b/services/frontend/src/api/client.ts index 33fa9d0..261e3aa 100644 --- a/services/frontend/src/api/client.ts +++ b/services/frontend/src/api/client.ts @@ -40,6 +40,9 @@ export const authApi = { verifyEmail: (token: string) => api.post(`/auth/verify-email/${token}`), + + resendVerification: () => + api.post('/auth/resend-verification'), }; export const tasksApi = { diff --git a/services/frontend/src/components/EmailVerificationModal.tsx b/services/frontend/src/components/EmailVerificationModal.tsx new file mode 100644 index 0000000..645c3b1 --- /dev/null +++ b/services/frontend/src/components/EmailVerificationModal.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Mail, X, CheckCircle2, AlertCircle } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { authApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; + +export function EmailVerificationModal() { + const { user, updateUser } = useAuthStore(); + const location = useLocation(); + const [dismissed, setDismissed] = useState(false); + const [verified, setVerified] = useState(false); + + // Не показывать на странице подтверждения — там своя UI + if (location.pathname.startsWith('/verify-email')) return null; + + const resend = useMutation({ + mutationFn: () => authApi.resendVerification(), + onSuccess: () => toast.success('Письмо отправлено, проверьте почту'), + onError: () => toast.error('Не удалось отправить письмо'), + }); + + useQuery({ + queryKey: ['email-verification-poll'], + queryFn: async () => { + const res = await authApi.me(); + if (res.data.is_verified) { + updateUser({ is_verified: true }); + setVerified(true); + } + return res.data; + }, + enabled: !!(user && !user.is_verified && !dismissed && !verified), + refetchInterval: 5000, + staleTime: 0, + }); + + if (!user || dismissed) return null; + if (!user.is_verified && !verified) { + return ( +
+
+ + +
+
+ +
+
+ +

+ Подтвердите ваш email +

+

{user.email}

+ + {/* Статус */} +
+ + Email не подтверждён +
+ +

+ Мы отправили письмо на{' '} + {user.email}. + Нажмите кнопку «Подтвердить» в письме для активации аккаунта. +

+ + + + +
+
+ ); + } + + if (verified) { + return ( +
+
+
+
+ +
+
+ +

Email подтверждён!

+ +
+ + Email подтверждён +
+ +

+ Аккаунт активирован. Теперь вы получаете уведомления о завершении задач. +

+ + +
+
+ ); + } + + return null; +} diff --git a/services/frontend/src/components/Layout.tsx b/services/frontend/src/components/Layout.tsx index abe849c..8696a74 100644 --- a/services/frontend/src/components/Layout.tsx +++ b/services/frontend/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { Link, NavLink, useNavigate } from 'react-router-dom'; import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react'; import { clsx } from 'clsx'; import { useAuthStore } from '../store/auth'; +import { EmailVerificationModal } from './EmailVerificationModal'; interface LayoutProps { children: React.ReactNode; @@ -144,6 +145,8 @@ export function Layout({ children }: LayoutProps) {
{children}
+ + ); } diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx index 3d0de64..d13083f 100644 --- a/services/frontend/src/main.tsx +++ b/services/frontend/src/main.tsx @@ -14,6 +14,7 @@ import { Task } from './pages/Task'; import { Pricing } from './pages/Pricing'; import { Login } from './pages/Login'; import { Register } from './pages/Register'; +import { VerifyEmail } from './pages/VerifyEmail'; import { AdminLayout } from './pages/admin/AdminLayout'; import { Dashboard } from './pages/admin/Dashboard'; import { Users as AdminUsers } from './pages/admin/Users'; @@ -48,6 +49,7 @@ function PublicApp() { } /> } /> } /> + } /> ); diff --git a/services/frontend/src/pages/VerifyEmail.tsx b/services/frontend/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..b689255 --- /dev/null +++ b/services/frontend/src/pages/VerifyEmail.tsx @@ -0,0 +1,83 @@ +import { useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'; +import { authApi } from '../api/client'; +import { useAuthStore } from '../store/auth'; + +export function VerifyEmail() { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const { updateUser } = useAuthStore(); + + const verify = useMutation({ + mutationFn: () => authApi.verifyEmail(token!), + onSuccess: () => { + updateUser({ is_verified: true }); + setTimeout(() => navigate('/cabinet'), 3000); + }, + }); + + useEffect(() => { + if (token) verify.mutate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + return ( +
+
+ {verify.isPending && ( + <> +
+ +
+

Подтверждаем email…

+

Пожалуйста, подождите

+ + )} + + {verify.isSuccess && ( + <> +
+
+ +
+
+

Email подтверждён!

+
+ + Email подтверждён +
+

+ Аккаунт активирован. Перенаправляем в личный кабинет… +

+ + )} + + {verify.isError && ( + <> +
+
+ +
+
+

Ошибка подтверждения

+
+ + Email не подтверждён +
+

+ Ссылка недействительна или устарела. Запросите новое письмо в личном кабинете. +

+ + В личный кабинет + + + )} +
+
+ ); +} diff --git a/services/worker-gpu/app/ollama_client.py b/services/worker-gpu/app/ollama_client.py index 6f853e9..ec4a369 100644 --- a/services/worker-gpu/app/ollama_client.py +++ b/services/worker-gpu/app/ollama_client.py @@ -15,7 +15,7 @@ class OllamaClient: def __init__(self) -> None: self.base_url = settings.OLLAMA_URL - self.model = "llama3:8b" + self.model = "qwen2.5:7b" self.timeout = 60.0 # секунд def check_paraphrase(self, text_a: str, text_b: str) -> dict: diff --git a/services/worker-notifier/app/config.py b/services/worker-notifier/app/config.py index e58aa98..aae597f 100644 --- a/services/worker-notifier/app/config.py +++ b/services/worker-notifier/app/config.py @@ -24,11 +24,12 @@ class Settings(BaseSettings): RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" # SMTP - SMTP_HOST: str = "smtp.yandex.ru" - SMTP_PORT: int = 465 - SMTP_USER: str = "noreply@jze9.ru" + SMTP_HOST: str = "mail.jze9mail.ru" + SMTP_PORT: int = 587 + SMTP_USER: str = "noreply" SMTP_PASSWORD: str = "changeme" - SMTP_FROM: str = "noreply@jze9.ru" + SMTP_FROM: str = "noreply@jze9mail.ru" + SMTP_TLS_VERIFY: bool = True # App APP_URL: str = "https://academic.jze9.ru" diff --git a/services/worker-notifier/app/email_sender.py b/services/worker-notifier/app/email_sender.py index 11d9964..6b79464 100644 --- a/services/worker-notifier/app/email_sender.py +++ b/services/worker-notifier/app/email_sender.py @@ -2,6 +2,7 @@ import logging import smtplib +import ssl from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -37,9 +38,22 @@ class EmailSender: msg.attach(MIMEText(html_body, "html", "utf-8")) try: - with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as smtp: - smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) - smtp.send_message(msg) + tls_ctx = ssl.create_default_context() + if not settings.SMTP_TLS_VERIFY: + tls_ctx.check_hostname = False + tls_ctx.verify_mode = ssl.CERT_NONE + + if settings.SMTP_PORT == 465: + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=tls_ctx) as smtp: + smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + smtp.send_message(msg) + else: + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp: + smtp.ehlo() + smtp.starttls(context=tls_ctx) + smtp.ehlo() + smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + smtp.send_message(msg) logger.info(f"Email отправлен: {to_email!r}, тема: {subject!r}") except smtplib.SMTPException as e: logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}")