diff --git a/serv_nginx/api_bb/.env b/serv_nginx/api_bb/.env index 5fdb436..4f7b17f 100644 --- a/serv_nginx/api_bb/.env +++ b/serv_nginx/api_bb/.env @@ -13,4 +13,12 @@ ENVIRONMENT=development # app REST_API_VERSION=1.0.0 -VITE_API_BASE_URL=https://begushiybashkir.ru \ No newline at end of file +VITE_API_BASE_URL=https://begushiybashkir.ru + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=valitovgaziz +SMTP_PASSWORD=omqywxnamignyeql +FROM_EMAIL=valitovgaziz@gmail.com +FRONTEND_URL=https://begushiybashkir.ru \ No newline at end of file diff --git a/serv_nginx/api_bb/go.mod b/serv_nginx/api_bb/go.mod index 3b72df5..4fd497a 100644 --- a/serv_nginx/api_bb/go.mod +++ b/serv_nginx/api_bb/go.mod @@ -23,6 +23,7 @@ require ( require ( github.com/go-playground/validator/v10 v10.28.0 + github.com/google/uuid v1.6.0 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -30,6 +31,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/joho/godotenv v1.5.1 + github.com/wneessen/go-mail v0.7.2 go.uber.org/zap v1.27.0 golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.30.0 // indirect diff --git a/serv_nginx/api_bb/go.sum b/serv_nginx/api_bb/go.sum index 04c9bb8..9a99ff9 100644 --- a/serv_nginx/api_bb/go.sum +++ b/serv_nginx/api_bb/go.sum @@ -17,6 +17,8 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0 github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -40,6 +42,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/serv_nginx/api_bb/internal/database/migrate.go b/serv_nginx/api_bb/internal/database/migrate.go index 4003f9e..8051101 100644 --- a/serv_nginx/api_bb/internal/database/migrate.go +++ b/serv_nginx/api_bb/internal/database/migrate.go @@ -26,6 +26,7 @@ func (d *Database) Migrate() error { &models.EventRegistration{}, &models.PersonalBest{}, &models.TrainingPlan{}, + &models.EmailVerification{}, // Добавьте другие модели здесь } @@ -96,6 +97,8 @@ func getModelName(model interface{}) string { return "Персональные достижения" case *models.TrainingPlan: return "Тренировочный план" + case *models.EmailVerification: + return "Верификация email" default: return "Unknown" } diff --git a/serv_nginx/api_bb/internal/handlers/auth.go b/serv_nginx/api_bb/internal/handlers/auth.go index 6107f37..8b26725 100644 --- a/serv_nginx/api_bb/internal/handlers/auth.go +++ b/serv_nginx/api_bb/internal/handlers/auth.go @@ -18,16 +18,18 @@ import ( ) type AuthHandler struct { - authService service.AuthService - jwtService service.JWTService - logger logger.LoggerInterface + authService service.AuthService + jwtService service.JWTService + logger logger.LoggerInterface + emailService service.EmailService } -func NewAuthHandler(authService service.AuthService, jwtService service.JWTService) *AuthHandler { +func NewAuthHandler(authService service.AuthService, jwtService service.JWTService, emailService service.EmailService) *AuthHandler { return &AuthHandler{ - authService: authService, - jwtService: jwtService, - logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "auth"))), + authService: authService, + jwtService: jwtService, + logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "auth"))), + emailService: emailService, } } @@ -136,6 +138,13 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { zap.String("email", user.Email), ) + // Отправки сообщения для верификации Email + if err := h.emailService.SendVerificationEmail(user.ID, user.Email, user.FirstName); err != nil { + h.logger.Error("failed to send verification email", + zap.Error(err), + zap.Uint("user_id", user.ID)) + } + // После успешной регистрации возвращаем данные пользователя utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{ "message": "User registered successfully", diff --git a/serv_nginx/api_bb/internal/handlers/email_handler.go b/serv_nginx/api_bb/internal/handlers/email_handler.go new file mode 100644 index 0000000..86ff964 --- /dev/null +++ b/serv_nginx/api_bb/internal/handlers/email_handler.go @@ -0,0 +1,223 @@ +// handlers/email_handler.go +package handlers + +import ( + "net/http" + + "api_bb/internal/models" + "api_bb/internal/service" + "api_bb/pkg/logger" + "api_bb/pkg/middleware" + "api_bb/pkg/utils" + + "go.uber.org/zap" +) + +type EmailHandler struct { + logger logger.LoggerInterface + emailService *service.EmailService +} + +func NewEmailHandler(emailService *service.EmailService) *EmailHandler { + return &EmailHandler{ + logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "email"))), + emailService: emailService, + } +} + +// VerifyEmail подтверждает email пользователя +func (h *EmailHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling email verification request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + token := r.URL.Query().Get("token") + if token == "" { + h.logger.Warn("email verification failed - token is required") + utils.RespondWithError(w, http.StatusBadRequest, "Токен обязателен") + return + } + + if err := h.emailService.VerifyEmail(token); err != nil { + h.logger.Error("email verification failed, expired", + zap.Error(err), + zap.String("token", token), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Неверный или просроченный токен") + return + } + + h.logger.Info("email successfully verified", + zap.String("token", token), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{ + "message": "Email успешно подтвержден", + }) +} + +// RequestPasswordReset запрашивает сброс пароля +func (h *EmailHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling password reset request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + var req models.PasswordResetRequest + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + h.logger.Warn("password reset request failed - invalid request format", + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса") + return + } + + if err := h.emailService.SendPasswordResetEmail(req.Email); err != nil { + h.logger.Error("password reset request failed", + zap.Error(err), + zap.String("email", req.Email), + ) + // Для безопасности всегда возвращаем успех + } + + h.logger.Info("password reset request processed", + zap.String("email", req.Email), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{ + "message": "Если email зарегистрирован, инструкции по восстановлению пароля будут отправлены", + }) +} + +// ConfirmPasswordReset подтверждает сброс пароля +func (h *EmailHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling password reset confirmation request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + var req models.PasswordResetConfirm + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + h.logger.Warn("password reset confirmation failed - invalid request format", + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса") + return + } + + if err := h.emailService.ResetPassword(req.Token, req.Password); err != nil { + h.logger.Error("password reset confirmation failed", + zap.Error(err), + zap.String("token", req.Token), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Неверный или просроченный токен") + return + } + + h.logger.Info("password successfully reset", + zap.String("token", req.Token), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{ + "message": "Пароль успешно изменен", + }) +} + +type NewsletterRequest struct { + Subject string `json:"subject" validate:"required"` + Content string `json:"content" validate:"required"` +} + +// SendNewsletter отправляет рассылку новостей +func (h *EmailHandler) SendNewsletter(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling newsletter sending request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + var req NewsletterRequest + if err := utils.DecodeJSONBody(w, r, &req); err != nil { + h.logger.Warn("newsletter sending failed - invalid request format", + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса") + return + } + + if err := h.emailService.SendNewsletterToSubscribers(req.Subject, req.Content); err != nil { + h.logger.Error("newsletter sending failed", + zap.Error(err), + zap.String("subject", req.Subject), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Не удалось отправить рассылку") + return + } + + h.logger.Info("newsletter sent successfully", + zap.String("subject", req.Subject), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{ + "message": "Рассылка отправлена подписчикам", + }) +} + +// ResendVerification повторно отправляет email верификации +func (h *EmailHandler) ResendVerification(w http.ResponseWriter, r *http.Request) { + h.logger.Info("handling resend verification request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("remote_addr", r.RemoteAddr), + ) + + user, ok := middleware.GetUserFromContext(r.Context()) + if !ok { + h.logger.Warn("resend verification failed - authentication required") + utils.RespondWithError(w, http.StatusUnauthorized, "Пользователь не авторизован") + return + } + + // Получаем пользователя + userData, err := h.emailService.GetUserByID(user.ID) + if err != nil { + h.logger.Warn("resend verification failed - user not found", + zap.Uint("user_id", user.ID), + zap.Error(err), + ) + utils.RespondWithError(w, http.StatusNotFound, "Пользователь не найден") + return + } + + if userData.EmailVerified { + h.logger.Warn("resend verification failed - email already verified", + zap.Uint("user_id", user.ID), + zap.String("email", userData.Email), + ) + utils.RespondWithError(w, http.StatusBadRequest, "Email уже подтвержден") + return + } + + if err := h.emailService.SendVerificationEmail(userData.ID, userData.Email, userData.FirstName); err != nil { + h.logger.Error("resend verification failed", + zap.Error(err), + zap.Uint("user_id", user.ID), + zap.String("email", userData.Email), + ) + utils.RespondWithError(w, http.StatusInternalServerError, "Не удалось отправить email подтверждения") + return + } + + h.logger.Info("verification email resent successfully", + zap.Uint("user_id", user.ID), + zap.String("email", userData.Email), + ) + + utils.RespondWithJSON(w, http.StatusOK, map[string]string{ + "message": "Email подтверждения отправлен повторно", + }) +} diff --git a/serv_nginx/api_bb/internal/handlers/handlers.go b/serv_nginx/api_bb/internal/handlers/handlers.go index 2b095b7..60ca5a7 100644 --- a/serv_nginx/api_bb/internal/handlers/handlers.go +++ b/serv_nginx/api_bb/internal/handlers/handlers.go @@ -5,8 +5,10 @@ import ( "api_bb/internal/config" "api_bb/internal/repository" "api_bb/internal/service" + "api_bb/pkg/email" "api_bb/pkg/logger" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -24,6 +26,7 @@ type Handler struct { eventRegistrationHandler *EventRegistrationHandler personalBestHandler *PersonalBestHandler trainingPlanHandler *TrainingPlanHandler + emailHandler *EmailHandler // Здесь будут добавлены другие обработчики // userHandler *UserHandler // eventHandler *EventHandler @@ -43,10 +46,17 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { eventRegistrationRepo := repository.NewEventRegistrationRepository(db) personalBestRepo := repository.NewPersonalBestRepository(db) trainingPlanRepo := repository.NewTrainingPlanRepository(db) + emailRepo := repository.NewEmailRepository(db) // Initialize logger baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер + // getConfig + emailSender, err := email.NewService(config.Load()) + if err != nil { + baseLogger.Info("error to load config", zap.Error(err)) + } + // Инициализация сервисов jwtService := service.NewJWTService(cfg.JWTSecret) authService := service.NewAuthService(userRepo, jwtService, baseLogger) @@ -61,10 +71,11 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { eventService := service.NewEventService(eventRepo, eventRegistrationRepo, baseLogger) personalBestService := service.NewPersonalBestService(personalBestRepo, userStatsService) trainingPlanService := service.NewTrainingPlanService(*trainingPlanRepo) + emailService := service.NewEmailService(*emailRepo, userRepo, *emailSender) // Инициализация обработчиков healthHandler := NewHealthHandler() - authHandler := NewAuthHandler(authService, jwtService) + authHandler := NewAuthHandler(authService, jwtService, emailService) userHandler := NewUserHandler(&userService) newsHandler := NewNewsHandler(newsService, baseLogger) avatarHandler := NewAvatarHandler(avatarService) @@ -76,6 +87,7 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { eventRegistrationHandler := NewEventRegistrationHandler(eventRegistrationService) personalBestHandler := NewPersonalBestHandler(*personalBestService) trainingPlanHandler := NewTrainingPlanHandler(trainingPlanService) + emailHandler := NewEmailHandler(&emailService) return &Handler{ healthHandler: healthHandler, @@ -91,10 +103,15 @@ func NewHandler(db *gorm.DB, cfg *config.Config) *Handler { eventRegistrationHandler: eventRegistrationHandler, personalBestHandler: personalBestHandler, trainingPlanHandler: trainingPlanHandler, + emailHandler: emailHandler, } } // Геттеры для обработчиков (опционально, для удобства) +func (h *Handler) EmailHandler() *EmailHandler { + return h.emailHandler +} + func (h *Handler) TrainingPlanHandler() *TrainingPlanHandler { return h.trainingPlanHandler } diff --git a/serv_nginx/api_bb/internal/models/user.go b/serv_nginx/api_bb/internal/models/user.go index 75fc88b..a16d1f3 100644 --- a/serv_nginx/api_bb/internal/models/user.go +++ b/serv_nginx/api_bb/internal/models/user.go @@ -10,20 +10,22 @@ import ( // models/user.go - добавить поле Avatar type User struct { - ID uint `json:"id" gorm:"primaryKey"` - Email string `json:"email" gorm:"uniqueIndex;not null"` - Password string `json:"-" gorm:"not null"` - FirstName string `json:"first_name" gorm:"not null"` - LastName string `json:"last_name" gorm:"not null"` - Avatar string `json:"avatar"` // Путь к файлу аватара - Phone string `json:"phone"` - Experience string `json:"experience"` - Goals string `json:"goals"` - Newsletter bool `json:"newsletter"` - Role string `json:"role" gorm:"default:user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + ID uint `json:"id" gorm:"primaryKey"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + Password string `json:"-" gorm:"not null"` + FirstName string `json:"first_name" gorm:"not null"` + LastName string `json:"last_name" gorm:"not null"` + Avatar string `json:"avatar"` // Путь к файлу аватара + Phone string `json:"phone"` + Experience string `json:"experience"` + Goals string `json:"goals"` + Newsletter bool `json:"newsletter"` + Role string `json:"role" gorm:"default:user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + EmailVerified bool `json:"email_verified" gorm:"default:false"` + VerifiedAt time.Time `json:"verified_at"` // Связи Workouts []Workout `json:"workouts,omitempty" gorm:"foreignKey:UserID"` diff --git a/serv_nginx/api_bb/internal/repository/email_repository.go b/serv_nginx/api_bb/internal/repository/email_repository.go new file mode 100644 index 0000000..be40f2c --- /dev/null +++ b/serv_nginx/api_bb/internal/repository/email_repository.go @@ -0,0 +1,83 @@ +// repository/email_repository.go +package repository + +import ( + "time" + + "api_bb/internal/models" + "gorm.io/gorm" +) + +type EmailRepository struct { + db *gorm.DB +} + +func NewEmailRepository(db *gorm.DB) *EmailRepository { + return &EmailRepository{db: db} +} + +func (r *EmailRepository) CreateVerificationToken(verification *models.EmailVerification) error { + return r.db.Create(verification).Error +} + +func (r *EmailRepository) GetVerificationToken(token string) (*models.EmailVerification, error) { + var verification models.EmailVerification + err := r.db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()). + Preload("User"). + First(&verification).Error + if err != nil { + return nil, err + } + return &verification, nil +} + +func (r *EmailRepository) MarkTokenAsUsed(token string) error { + return r.db.Model(&models.EmailVerification{}). + Where("token = ?", token). + Updates(map[string]interface{}{ + "used": true, + "updated_at": time.Now(), + }).Error +} + +func (r *EmailRepository) DeleteExpiredTokens() error { + return r.db.Where("expires_at < ?", time.Now()).Delete(&models.EmailVerification{}).Error +} + +func (r *EmailRepository) GetUsersWithNewsletter() ([]models.User, error) { + var users []models.User + err := r.db.Where("newsletter = ? AND email_verified = ?", true, true). + Find(&users).Error + return users, err +} + +// MarkEmailAsVerified помечает email пользователя как верифицированный +func (r *EmailRepository) MarkEmailAsVerified(userID uint) error { + return r.db.Model(&models.User{}). + Where("id = ?", userID). + Updates(map[string]interface{}{ + "email_verified": true, + "updated_at": time.Now(), + }).Error +} + +// GetUserByEmail возвращает пользователя по email +func (r *EmailRepository) GetUserByEmail(email string) (*models.User, error) { + var user models.User + err := r.db.Where("email = ?", email).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// UpdatePassword обновляет пароль пользователя +func (r *EmailRepository) UpdatePassword(userID uint, newPassword string) error { + return r.db.Model(&models.User{}). + Where("id = ?", userID). + Updates(map[string]interface{}{ + "password": newPassword, + "updated_at": time.Now(), + }).Error +} + diff --git a/serv_nginx/api_bb/internal/repository/user_repository.go b/serv_nginx/api_bb/internal/repository/user_repository.go index 3c8d39f..755f123 100644 --- a/serv_nginx/api_bb/internal/repository/user_repository.go +++ b/serv_nginx/api_bb/internal/repository/user_repository.go @@ -16,6 +16,9 @@ type UserRepository interface { UpdateExcludeEmail(userUpdate *models.User) error UpdateAvatar(userID uint, avatarPath string) error FindAll() ([]models.User, error) + MarkEmailAsVerified(userID uint) error + UpdatePassword(userID uint, newPassword string) error + GetUserByID(id uint) (*models.User, error) } func (r *userRepository) UpdateAvatar(userID uint, avatarPath string) error { @@ -91,3 +94,33 @@ func (r *userRepository) UpdateExcludeEmail(userUpdate *models.User) error { return nil } + +// MarkEmailAsVerified помечает email пользователя как верифицированный +func (r userRepository) MarkEmailAsVerified(userID uint) error { + result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("email_verified", true) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("user not found") + } + return nil +} + +// UpdatePassword обновляет пароль пользователя +func (r userRepository) UpdatePassword(userID uint, newPassword string) error { + result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("password", newPassword) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("user not found") + } + return nil +} + +func (r userRepository) GetUserByID(id uint) (*models.User, error) { + var user models.User + err := r.db.First(&user, id).Error + return &user, err +} diff --git a/serv_nginx/api_bb/internal/routes/routes.go b/serv_nginx/api_bb/internal/routes/routes.go index 533c123..5d91709 100644 --- a/serv_nginx/api_bb/internal/routes/routes.go +++ b/serv_nginx/api_bb/internal/routes/routes.go @@ -34,12 +34,16 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { userRepo := repository.NewUserRepository(db) // Initialize logger - baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер + baseLogger := logger.NewWrapper(logger.Get()) // Initialize services with logger jwtService := service.NewJWTService(config.JWTSecret) - // Initialize handlers + // Email service initialization with fallback + var emailHandler *handlers.EmailHandler + if h.EmailHandler() != nil { + emailHandler = h.EmailHandler() + } // Health routes r.Route("/api", func(r chi.Router) { @@ -49,12 +53,24 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { // API v1 routes r.Route("/v1", func(r chi.Router) { + + // Email verification (public) - только если доступен + if emailHandler != nil { + r.Get("/verify-email", emailHandler.VerifyEmail) + } // Public auth routes r.Route("/auth", func(r chi.Router) { r.Post("/register", h.AuthHandler().Register) r.Post("/login", h.AuthHandler().Login) r.Post("/logout", h.AuthHandler().Logout) + + // Email routes (only if email handler is available) + if emailHandler != nil { + r.Post("/verify-email/resend", emailHandler.ResendVerification) + r.Post("/password-reset/request", emailHandler.RequestPasswordReset) + r.Post("/password-reset/confirm", emailHandler.ConfirmPasswordReset) + } }) // Публичные маршруты для достижений (если нужны) @@ -238,6 +254,7 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { }) }) + // Events r.Route("/events", func(r chi.Router) { // Публичные маршруты diff --git a/serv_nginx/api_bb/internal/service/email_service.go b/serv_nginx/api_bb/internal/service/email_service.go new file mode 100644 index 0000000..26e16de --- /dev/null +++ b/serv_nginx/api_bb/internal/service/email_service.go @@ -0,0 +1,297 @@ +// service/email_service.go +package service + +import ( + "fmt" + "time" + + "api_bb/internal/models" + "api_bb/internal/repository" + "api_bb/pkg/email" + "api_bb/pkg/logger" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type EmailService struct { + emailRepo repository.EmailRepository + userRepo repository.UserRepository + emailSender email.Service + logger *zap.Logger + tokenExpiry time.Duration + passwordExpiry time.Duration +} + +func NewEmailService( + emailRepo repository.EmailRepository, + userRepo repository.UserRepository, + emailSender email.Service, +) EmailService { + // Создаем логгер с контекстом для сервиса + serviceLogger := logger.Get().With(zap.String("service", "email")) + + return EmailService{ + emailRepo: emailRepo, + userRepo: userRepo, + emailSender: emailSender, + logger: serviceLogger, + tokenExpiry: 24 * time.Hour, // 24 часа для верификации + passwordExpiry: 1 * time.Hour, // 1 час для сброса пароля + } +} + +func (s *EmailService) SendVerificationEmail(userID uint, email, userName string) error { + s.logger.Info("Sending verification email", + zap.Uint("user_id", userID), + zap.String("email", email), + ) + + token := uuid.New().String() + + verification := &models.EmailVerification{ + UserID: userID, + Token: token, + Email: email, + Type: "verification", + ExpiresAt: time.Now().Add(s.tokenExpiry), + } + + if err := s.emailRepo.CreateVerificationToken(verification); err != nil { + s.logger.Error("Failed to create verification token", + zap.Uint("user_id", userID), + zap.String("email", email), + zap.Error(err), + ) + return fmt.Errorf("failed to create verification token: %w", err) + } + + if err := s.emailSender.SendVerificationEmail(email, userName, token); err != nil { + s.logger.Error("Failed to send verification email", + zap.Uint("user_id", userID), + zap.String("email", email), + zap.Error(err), + ) + return fmt.Errorf("failed to send verification email: %w", err) + } + + s.logger.Info("Verification email sent successfully", + zap.Uint("user_id", userID), + zap.String("email", email)) + return nil +} + +func (s *EmailService) VerifyEmail(token string) error { + s.logger.Info("Verifying email token", + zap.String("token", token), + ) + + verification, err := s.emailRepo.GetVerificationToken(token) + if err != nil { + s.logger.Error("Invalid or expired verification token", + zap.String("token", token), + zap.Error(err), + ) + return fmt.Errorf("invalid or expired token: %w", err) + } + + if verification.Type != "verification" { + s.logger.Error("Invalid token type for email verification", + zap.String("token", token), + zap.String("type", verification.Type), + ) + return fmt.Errorf("invalid token type") + } + + // Обновляем пользователя + if err := s.userRepo.MarkEmailAsVerified(verification.UserID); err != nil { + s.logger.Error("Failed to verify email in user repository", + zap.Uint("user_id", verification.UserID), + zap.String("email", verification.Email), + zap.Error(err), + ) + return fmt.Errorf("failed to verify email: %w", err) + } + + // Помечаем токен как использованный + if err := s.emailRepo.MarkTokenAsUsed(token); err != nil { + s.logger.Error("Failed to mark token as used", + zap.Error(err), + zap.String("token", token)) + } + + s.logger.Info("Email verified successfully", + zap.Uint("user_id", verification.UserID), + zap.String("email", verification.Email)) + return nil +} + +func (s *EmailService) SendPasswordResetEmail(email string) error { + s.logger.Info("Sending password reset email", + zap.String("email", email), + ) + + user, err := s.userRepo.FindByEmail(email) + if err != nil { + // Для безопасности не сообщаем, существует ли email + s.logger.Info("Password reset requested for non-existent email", + zap.String("email", email)) + return nil + } + + token := uuid.New().String() + + resetRequest := &models.EmailVerification{ + UserID: user.ID, + Token: token, + Email: email, + Type: "password_reset", + ExpiresAt: time.Now().Add(s.passwordExpiry), + } + + if err := s.emailRepo.CreateVerificationToken(resetRequest); err != nil { + s.logger.Error("Failed to create password reset token", + zap.Uint("user_id", user.ID), + zap.String("email", email), + zap.Error(err), + ) + return fmt.Errorf("failed to create password reset token: %w", err) + } + + if err := s.emailSender.SendPasswordResetEmail(email, user.FirstName, token); err != nil { + s.logger.Error("Failed to send password reset email", + zap.Uint("user_id", user.ID), + zap.String("email", email), + zap.Error(err), + ) + return fmt.Errorf("failed to send password reset email: %w", err) + } + + s.logger.Info("Password reset email sent successfully", + zap.Uint("user_id", user.ID), + zap.String("email", email)) + return nil +} + +func (s *EmailService) ResetPassword(token, newPassword string) error { + s.logger.Info("Resetting password with token", + zap.String("token", token), + ) + + verification, err := s.emailRepo.GetVerificationToken(token) + if err != nil { + s.logger.Error("Invalid or expired password reset token", + zap.String("token", token), + zap.Error(err), + ) + return fmt.Errorf("invalid or expired token: %w", err) + } + + if verification.Type != "password_reset" { + s.logger.Error("Invalid token type for password reset", + zap.String("token", token), + zap.String("type", verification.Type), + ) + return fmt.Errorf("invalid token type") + } + + // Обновляем пароль пользователя + if err := s.userRepo.UpdatePassword(verification.UserID, newPassword); err != nil { + s.logger.Error("Failed to update password", + zap.Uint("user_id", verification.UserID), + zap.Error(err), + ) + return fmt.Errorf("failed to update password: %w", err) + } + + // Помечаем токен как использованный + if err := s.emailRepo.MarkTokenAsUsed(token); err != nil { + s.logger.Error("Failed to mark token as used", + zap.Error(err), + zap.String("token", token)) + } + + s.logger.Info("Password reset successfully", + zap.Uint("user_id", verification.UserID)) + return nil +} + +func (s *EmailService) SendNewsletterToSubscribers(subject, content string) error { + s.logger.Info("Sending newsletter to subscribers", + zap.String("subject", subject), + ) + + subscribers, err := s.emailRepo.GetUsersWithNewsletter() + if err != nil { + s.logger.Error("Failed to get subscribers", + zap.Error(err), + ) + return fmt.Errorf("failed to get subscribers: %w", err) + } + + s.logger.Debug("Found subscribers for newsletter", + zap.Int("count", len(subscribers)), + ) + + var errors []error + for _, user := range subscribers { + if err := s.emailSender.SendNewsletterEmail(user.Email, user.FirstName, subject, content); err != nil { + s.logger.Error("Failed to send newsletter to user", + zap.Uint("user_id", user.ID), + zap.String("email", user.Email), + zap.Error(err)) + errors = append(errors, err) + continue + } + s.logger.Debug("Newsletter sent to user", + zap.Uint("user_id", user.ID), + zap.String("email", user.Email)) + } + + if len(errors) > 0 { + s.logger.Error("Failed to send newsletter to some users", + zap.Int("failed_count", len(errors)), + zap.Int("total_subscribers", len(subscribers)), + ) + return fmt.Errorf("failed to send newsletter to %d users", len(errors)) + } + + s.logger.Info("Newsletter sent to all subscribers", + zap.Int("total_subscribers", len(subscribers))) + return nil +} + +func (s *EmailService) CleanupExpiredTokens() error { + s.logger.Info("Cleaning up expired tokens") + + if err := s.emailRepo.DeleteExpiredTokens(); err != nil { + s.logger.Error("Failed to cleanup expired tokens", + zap.Error(err), + ) + return fmt.Errorf("failed to cleanup expired tokens: %w", err) + } + s.logger.Info("Expired tokens cleaned up successfully") + return nil +} + +// GetUserByID возвращает пользователя по ID +func (s *EmailService) GetUserByID(userID uint) (*models.User, error) { + s.logger.Info("Getting user by ID", + zap.Uint("user_id", userID), + ) + + user, err := s.userRepo.GetUserByID(userID) + if err != nil { + s.logger.Error("Failed to get user by ID", + zap.Uint("user_id", userID), + zap.Error(err), + ) + return nil, fmt.Errorf("failed to get user: %w", err) + } + + s.logger.Debug("User retrieved successfully", + zap.Uint("user_id", userID), + zap.String("email", user.Email), + ) + return user, nil +} diff --git a/serv_nginx/api_bb/internal/service/user_service.go b/serv_nginx/api_bb/internal/service/user_service.go index ac6de6b..1d437bd 100644 --- a/serv_nginx/api_bb/internal/service/user_service.go +++ b/serv_nginx/api_bb/internal/service/user_service.go @@ -135,3 +135,5 @@ func (s *userService) GetUserProfile(userID uint) (*models.User, error) { return user, nil } + + diff --git a/serv_nginx/api_bb/pkg/email/email.go b/serv_nginx/api_bb/pkg/email/email.go new file mode 100644 index 0000000..5b4ce56 --- /dev/null +++ b/serv_nginx/api_bb/pkg/email/email.go @@ -0,0 +1,441 @@ +// pkg/email/email.go +package email + +import ( + "bytes" + "fmt" + "html/template" + "time" + + "api_bb/internal/config" + "api_bb/pkg/logger" + + "github.com/wneessen/go-mail" + "go.uber.org/zap" +) + +// Service представляет сервис для отправки email +type Service struct { + client *mail.Client + config *config.Config + logger *zap.Logger + tmpl *template.Template + fromAddr string + isActive bool +} + +// NewService создает новый экземпляр email сервиса +func NewService(cfg *config.Config) (*Service, error) { + log := logger.Get() + log.Info("Initializing email service") + + // Проверяем обязательные параметры конфигурации + if err := validateConfig(cfg); err != nil { + log.Warn("Email service configuration is invalid, service will be disabled", zap.Error(err)) + return &Service{ + logger: log, + isActive: false, + }, nil + } + + // Создаем SMTP клиент с правильными настройками + client, err := createSMTPClient(cfg) + if err != nil { + log.Warn("Failed to create SMTP client, email service will be disabled", zap.Error(err)) + return &Service{ + logger: log, + isActive: false, + }, nil + } + + // Загружаем шаблоны писем + tmpl, err := loadTemplates() + if err != nil { + log.Warn("Failed to load email templates, email service will be disabled", zap.Error(err)) + return &Service{ + logger: log, + isActive: false, + }, nil + } + + service := &Service{ + client: client, + config: cfg, + logger: log, + tmpl: tmpl, + fromAddr: cfg.FromEmail, + isActive: true, + } + + log.Info("Email service initialized successfully", + zap.String("host", cfg.SMTPHost), + zap.Int("port", cfg.SMTPPort), + zap.String("from", cfg.FromEmail)) + + return service, nil +} + +// validateConfig проверяет корректность конфигурации email +func validateConfig(cfg *config.Config) error { + if cfg.SMTPHost == "" { + return fmt.Errorf("SMTP host is required") + } + + if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 { + return fmt.Errorf("invalid SMTP port: %d", cfg.SMTPPort) + } + + if cfg.SMTPUsername == "" { + return fmt.Errorf("SMTP username is required") + } + + if cfg.SMTPPassword == "" { + return fmt.Errorf("SMTP password is required") + } + + if cfg.FromEmail == "" { + return fmt.Errorf("from email is required") + } + + if cfg.FrontendURL == "" { + return fmt.Errorf("frontend URL is required") + } + + return nil +} + +// createSMTPClient создает SMTP клиент с правильными настройками +func createSMTPClient(cfg *config.Config) (*mail.Client, error) { + opts := []mail.Option{ + mail.WithPort(cfg.SMTPPort), + mail.WithSMTPAuth(mail.SMTPAuthPlain), + mail.WithUsername(cfg.SMTPUsername), + mail.WithPassword(cfg.SMTPPassword), + } + + // Настраиваем TLS в зависимости от порта + switch cfg.SMTPPort { + case 587: + // STARTTLS для порта 587 + opts = append(opts, mail.WithTLSPolicy(mail.TLSMandatory)) + case 465: + // SSL/TLS для порта 465 + opts = append(opts, mail.WithSSL()) + default: + // Opportunistic TLS для других портов + opts = append(opts, mail.WithTLSPolicy(mail.TLSOpportunistic)) + } + + return mail.NewClient(cfg.SMTPHost, opts...) +} + +// loadTemplates загружает HTML шаблоны для писем +func loadTemplates() (*template.Template, error) { + tmpl := template.New("email") + + templates := map[string]string{ + "verification": verificationTemplate, + "password_reset": passwordResetTemplate, + "newsletter": newsletterTemplate, + } + + for name, content := range templates { + var err error + tmpl, err = tmpl.New(name).Parse(content) + if err != nil { + return nil, fmt.Errorf("failed to parse template %s: %w", name, err) + } + } + + return tmpl, nil +} + +// IsActive возвращает статус сервиса +func (s *Service) IsActive() bool { + return s.isActive +} + +// EmailData содержит данные для шаблонов писем +type EmailData struct { + UserName string + AppName string + FrontendURL string + Token string + Subject string + Content string + Year int +} + +// SendVerificationEmail отправляет email для подтверждения адреса +func (s *Service) SendVerificationEmail(to, userName, token string) error { + if !s.isActive { + s.logger.Warn("Email service is disabled, skipping verification email", + zap.String("to", to), zap.String("user", userName)) + return nil + } + + s.logger.Info("Sending verification email", zap.String("to", to), zap.String("user", userName)) + + data := EmailData{ + UserName: userName, + AppName: "Бегущий Башкир", + FrontendURL: s.config.FrontendURL, + Token: token, + Subject: "Подтверждение email", + Year: time.Now().Year(), + } + + return s.sendEmail(to, "Подтверждение email - Бегущий Башкир", "verification", data) +} + +// SendPasswordResetEmail отправляет email для сброса пароля +func (s *Service) SendPasswordResetEmail(to, userName, token string) error { + if !s.isActive { + s.logger.Warn("Email service is disabled, skipping password reset email", + zap.String("to", to), zap.String("user", userName)) + return nil + } + + s.logger.Info("Sending password reset email", zap.String("to", to), zap.String("user", userName)) + + data := EmailData{ + UserName: userName, + AppName: "Бегущий Башкир", + FrontendURL: s.config.FrontendURL, + Token: token, + Subject: "Восстановление пароля", + Year: time.Now().Year(), + } + + return s.sendEmail(to, "Восстановление пароля - Бегущий Башкир", "password_reset", data) +} + +// SendNewsletterEmail отправляет email рассылку +func (s *Service) SendNewsletterEmail(to, userName, subject, content string) error { + if !s.isActive { + s.logger.Warn("Email service is disabled, skipping newsletter", + zap.String("to", to), zap.String("user", userName), zap.String("subject", subject)) + return nil + } + + s.logger.Info("Sending newsletter email", + zap.String("to", to), + zap.String("user", userName), + zap.String("subject", subject)) + + data := EmailData{ + UserName: userName, + AppName: "Бегущий Башкир", + FrontendURL: s.config.FrontendURL, + Subject: subject, + Content: content, + Year: time.Now().Year(), + } + + // Для новостей используем специальный шаблон + var body bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&body, "newsletter", data); err != nil { + s.logger.Error("Failed to execute newsletter template", zap.Error(err)) + return fmt.Errorf("failed to execute newsletter template: %w", err) + } + + msg := mail.NewMsg() + if err := msg.From(s.fromAddr); err != nil { + return fmt.Errorf("failed to set from address: %w", err) + } + if err := msg.To(to); err != nil { + return fmt.Errorf("failed to set to address: %w", err) + } + + msg.Subject(subject) + msg.SetBodyString(mail.TypeTextHTML, body.String()) + + if err := s.client.DialAndSend(msg); err != nil { + s.logger.Error("Failed to send newsletter email", zap.Error(err)) + return fmt.Errorf("failed to send newsletter email: %w", err) + } + + s.logger.Info("Newsletter email sent successfully", zap.String("to", to)) + return nil +} + +// sendEmail общий метод для отправки email +func (s *Service) sendEmail(to, subject, templateName string, data EmailData) error { + var body bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&body, templateName, data); err != nil { + s.logger.Error("Failed to execute email template", + zap.String("template", templateName), + zap.Error(err)) + return fmt.Errorf("failed to execute template %s: %w", templateName, err) + } + + msg := mail.NewMsg() + if err := msg.From(s.fromAddr); err != nil { + return fmt.Errorf("failed to set from address: %w", err) + } + if err := msg.To(to); err != nil { + return fmt.Errorf("failed to set to address: %w", err) + } + + msg.Subject(subject) + msg.SetBodyString(mail.TypeTextHTML, body.String()) + + if err := s.client.DialAndSend(msg); err != nil { + s.logger.Error("Failed to send email", + zap.String("type", templateName), + zap.String("to", to), + zap.Error(err)) + return fmt.Errorf("failed to send email: %w", err) + } + + s.logger.Info("Email sent successfully", + zap.String("type", templateName), + zap.String("to", to)) + return nil +} + +// TestConnection тестирует подключение к SMTP серверу +func (s *Service) TestConnection() error { + if !s.isActive { + return fmt.Errorf("email service is disabled") + } + + // Создаем тестовое сообщение + msg := mail.NewMsg() + if err := msg.From(s.fromAddr); err != nil { + return fmt.Errorf("failed to set from address: %w", err) + } + if err := msg.To(s.fromAddr); err != nil { + return fmt.Errorf("failed to set to address: %w", err) + } + + msg.Subject("Тестовое письмо - Бегущий Башкир") + msg.SetBodyString(mail.TypeTextPlain, "Это тестовое письмо для проверки подключения.") + + // Пытаемся отправить тестовое письмо + if err := s.client.DialAndSend(msg); err != nil { + return fmt.Errorf("failed to send test email: %w", err) + } + + s.logger.Info("SMTP connection test successful") + return nil +} + +// Шаблоны писем остаются без изменений... +const verificationTemplate = ` +{{define "verification"}} + + + + + + + +
+

🏃 Бегущий Башкир

+

Подтверждение email адреса

+
+
+

Привет, {{.UserName}}!

+

Благодарим за регистрацию в приложении "Бегущий Башкир". Для завершения регистрации подтвердите ваш email адрес.

+ + + Подтвердить Email + + +

Или скопируйте эту ссылку в браузер:

+
{{.FrontendURL}}/verify-email?token={{.Token}}
+ +

Ссылка действительна в течение 24 часов.

+
+ + + +{{end}} +` + +const passwordResetTemplate = ` +{{define "password_reset"}} + + + + + + + +
+

🏃 Бегущий Башкир

+

Восстановление пароля

+
+
+

Привет, {{.UserName}}!

+

Мы получили запрос на восстановление пароля для вашего аккаунта.

+ + + Восстановить пароль + + +

Или скопируйте эту ссылку в браузер:

+
{{.FrontendURL}}/reset-password?token={{.Token}}
+ +

Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.

+

Ссылка действительна в течение 1 часа.

+
+ + + +{{end}} +` + +const newsletterTemplate = ` +{{define "newsletter"}} + + + + + + + +
+

🏃 Бегущий Башкир

+

Новости и обновления

+
+
+

Привет, {{.UserName}}!

+
+ {{.Content}} +
+
+ + + +{{end}} +`