rename long name to short name

This commit is contained in:
2025-10-23 02:48:42 +05:00
parent df18d2083d
commit fd7a55f626
229 changed files with 39 additions and 40 deletions
+50
View File
@@ -0,0 +1,50 @@
package database
import (
"fmt"
"log"
"time"
"api_bb/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func InitDB(dsn string) (*gorm.DB, error) {
// Используем PostgreSQL драйвер
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Получаем underlying sql.DB для настройки пула соединений
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get database instance: %w", err)
}
// Настраиваем пул соединений
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
// Проверяем соединение
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("database ping failed: %w", err)
}
log.Println("PostgreSQL connection established successfully")
// Auto migrate models
err = db.AutoMigrate(
&models.User{},
// Добавьте другие модели здесь по мере расширения
)
if err != nil {
return nil, fmt.Errorf("failed to auto-migrate models: %w", err)
}
log.Println("Database migration completed successfully")
return db, nil
}
+441
View File
@@ -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"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
.content { padding: 2rem; background: #f8f9fa; }
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
</style>
</head>
<body>
<div class="header">
<h1>🏃 Бегущий Башкир</h1>
<p>Подтверждение email адреса</p>
</div>
<div class="content">
<h2>Привет, {{.UserName}}!</h2>
<p>Благодарим за регистрацию в приложении "Бегущий Башкир". Для завершения регистрации подтвердите ваш email адрес.</p>
<a href="{{.FrontendURL}}/verify-email?token={{.Token}}" class="cta-button">
Подтвердить Email
</a>
<p>Или скопируйте эту ссылку в браузер:</p>
<div class="token">{{.FrontendURL}}/verify-email?token={{.Token}}</div>
<p>Ссылка действительна в течение 24 часов.</p>
</div>
<div class="footer">
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
</div>
</body>
</html>
{{end}}
`
const passwordResetTemplate = `
{{define "password_reset"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #dc3545, #e35d6a); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
.content { padding: 2rem; background: #f8f9fa; }
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
.cta-button { display: inline-block; padding: 12px 24px; background: #dc3545; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
</style>
</head>
<body>
<div class="header">
<h1>🏃 Бегущий Башкир</h1>
<p>Восстановление пароля</p>
</div>
<div class="content">
<h2>Привет, {{.UserName}}!</h2>
<p>Мы получили запрос на восстановление пароля для вашего аккаунта.</p>
<a href="{{.FrontendURL}}/reset-password?token={{.Token}}" class="cta-button">
Восстановить пароль
</a>
<p>Или скопируйте эту ссылку в браузер:</p>
<div class="token">{{.FrontendURL}}/reset-password?token={{.Token}}</div>
<p>Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.</p>
<p>Ссылка действительна в течение 1 часа.</p>
</div>
<div class="footer">
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
</div>
</body>
</html>
{{end}}
`
const newsletterTemplate = `
{{define "newsletter"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
.content { padding: 2rem; background: #f8f9fa; }
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
.newsletter-content { line-height: 1.8; }
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
</style>
</head>
<body>
<div class="header">
<h1>🏃 Бегущий Башкир</h1>
<p>Новости и обновления</p>
</div>
<div class="content">
<h2>Привет, {{.UserName}}!</h2>
<div class="newsletter-content">
{{.Content}}
</div>
</div>
<div class="footer">
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
<p><a href="{{.FrontendURL}}/unsubscribe" style="color: #666;">Отписаться от рассылки</a></p>
</div>
</body>
</html>
{{end}}
`
+35
View File
@@ -0,0 +1,35 @@
// pkg/logger/helpers.go
package logger
import (
"time"
"go.uber.org/zap"
)
// LogApplicationStart логирует запуск приложения
func LogApplicationStart(version, environment, port string) {
Get().Info("application starting",
zap.String("version", version),
zap.String("environment", environment),
zap.String("port", port),
zap.Time("start_time", time.Now()),
)
}
// LogApplicationShutdown логирует graceful shutdown
func LogApplicationShutdown(reason string) {
Get().Info("application shutting down",
zap.String("reason", reason),
zap.Time("shutdown_time", time.Now()),
)
}
// LogDatabaseStats логирует статистику базы данных
func LogDatabaseStats(stats map[string]interface{}) {
fields := make([]zap.Field, 0, len(stats))
for key, value := range stats {
fields = append(fields, zap.Any(key, value))
}
Get().Info("database statistics", fields...)
}
+75
View File
@@ -0,0 +1,75 @@
// pkg/logger/interface.go
package logger
import "go.uber.org/zap"
// LoggerInterface определяет контракт для логгера
type LoggerInterface interface {
Debug(msg string, fields ...zap.Field)
Info(msg string, fields ...zap.Field)
Warn(msg string, fields ...zap.Field)
Error(msg string, fields ...zap.Field)
Fatal(msg string, fields ...zap.Field)
Debugf(template string, args ...interface{})
Infof(template string, args ...interface{})
Warnf(template string, args ...interface{})
Errorf(template string, args ...interface{})
Fatalf(template string, args ...interface{})
With(fields ...zap.Field) LoggerInterface
}
// wrapper обертка для zap.Logger
type wrapper struct {
logger *zap.Logger
}
// NewWrapper создает новую обертку
func NewWrapper(logger *zap.Logger) LoggerInterface {
return &wrapper{logger: logger}
}
func (w *wrapper) Debug(msg string, fields ...zap.Field) {
w.logger.Debug(msg, fields...)
}
func (w *wrapper) Info(msg string, fields ...zap.Field) {
w.logger.Info(msg, fields...)
}
func (w *wrapper) Warn(msg string, fields ...zap.Field) {
w.logger.Warn(msg, fields...)
}
func (w *wrapper) Error(msg string, fields ...zap.Field) {
w.logger.Error(msg, fields...)
}
func (w *wrapper) Fatal(msg string, fields ...zap.Field) {
w.logger.Fatal(msg, fields...)
}
func (w *wrapper) Debugf(template string, args ...interface{}) {
w.logger.Sugar().Debugf(template, args...)
}
func (w *wrapper) Infof(template string, args ...interface{}) {
w.logger.Sugar().Infof(template, args...)
}
func (w *wrapper) Warnf(template string, args ...interface{}) {
w.logger.Sugar().Warnf(template, args...)
}
func (w *wrapper) Errorf(template string, args ...interface{}) {
w.logger.Sugar().Errorf(template, args...)
}
func (w *wrapper) Fatalf(template string, args ...interface{}) {
w.logger.Sugar().Fatalf(template, args...)
}
func (w *wrapper) With(fields ...zap.Field) LoggerInterface {
return &wrapper{logger: w.logger.With(fields...)}
}
+66
View File
@@ -0,0 +1,66 @@
// pkg/logger/logger.go
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var globalLogger *zap.Logger
// Init инициализирует глобальный логгер
func Init(level string, environment string) error {
var config zap.Config
if environment == "production" {
config = zap.NewProductionConfig()
} else {
config = zap.NewDevelopmentConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
// Устанавливаем уровень логирования
switch level {
case "debug":
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
case "info":
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
case "warn":
config.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
case "error":
config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
default:
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
}
logger, err := config.Build()
if err != nil {
return err
}
globalLogger = logger
return nil
}
// Get возвращает глобальный логгер
func Get() *zap.Logger {
if globalLogger == nil {
// Fallback на стандартный логгер если не инициализирован
logger, _ := zap.NewProduction()
return logger
}
return globalLogger
}
// Sync синхронизирует буферы логгера
func Sync() {
if globalLogger != nil {
globalLogger.Sync()
}
}
// Sugar возвращает SugaredLogger
func Sugar() *zap.SugaredLogger {
return Get().Sugar()
}
+100
View File
@@ -0,0 +1,100 @@
package logger
import (
"net/http"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
type RouteLogger struct {
logger LoggerInterface
}
func NewRouteLogger(log LoggerInterface) *RouteLogger {
return &RouteLogger{
logger: log,
}
}
func (rl *RouteLogger) LogRoutes(router *chi.Mux) {
routes := rl.extractRoutes(router)
rl.printFormattedRoutes(routes)
}
func (rl *RouteLogger) extractRoutes(router *chi.Mux) []RouteInfo {
var routes []RouteInfo
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
if route != "" {
routes = append(routes, RouteInfo{
Method: method,
Path: route,
})
}
return nil
}
if err := chi.Walk(router, walkFunc); err != nil {
rl.logger.Error("Failed to walk routes", zap.Error(err))
}
return routes
}
func (rl *RouteLogger) printFormattedRoutes(routes []RouteInfo) {
if len(routes) == 0 {
rl.logger.Info("No routes found")
return
}
// Группируем по пути
routesByPath := make(map[string][]string)
for _, route := range routes {
routesByPath[route.Path] = append(routesByPath[route.Path], route.Method)
}
// Сортируем пути
var paths []string
for path := range routesByPath {
paths = append(paths, path)
}
sort.Strings(paths)
rl.logger.Info("📋 Registered API Routes:")
rl.logger.Info("┌──────────────────────────────────────────────────────────────┐")
for _, path := range paths {
methods := routesByPath[path]
sort.Strings(methods)
methodsStr := strings.Join(methods, ", ")
if len(methodsStr) > 12 {
methodsStr = methodsStr[:9] + "..."
}
methodField := methodsStr
if len(methodField) < 12 {
methodField = methodField + strings.Repeat(" ", 12-len(methodField))
}
pathField := path
if len(pathField) > 45 {
pathField = pathField[:42] + "..."
} else {
pathField = pathField + strings.Repeat(" ", 45-len(pathField))
}
rl.logger.Info("│ " + methodField + " " + pathField + " │")
}
rl.logger.Info("└──────────────────────────────────────────────────────────────┘")
rl.logger.Info("Total routes registered: %d", zap.Int("count", len(routes)))
}
type RouteInfo struct {
Method string
Path string
}
@@ -0,0 +1,49 @@
// pkg/middleware/admin_middleware.go
package middleware
import (
"api_bb/pkg/logger"
"api_bb/pkg/utils"
"net/http"
"go.uber.org/zap"
)
// AdminMiddleware проверяет, что пользователь имеет роль администратора
func AdminMiddleware(next http.Handler) http.Handler {
logger := logger.NewWrapper(logger.Get().With(zap.String("middleware", "admin")))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Info("admin middleware check",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
// Получаем пользователя из контекста
user, ok := GetUserFromContext(r.Context())
if !ok {
logger.Warn("admin middleware failed - user not found in context")
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
return
}
// Проверяем роль пользователя
if user.Role != "admin" {
logger.Warn("admin middleware failed - insufficient permissions",
zap.Uint("user_id", user.ID),
zap.String("user_role", user.Role),
zap.String("required_role", "admin"),
)
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions: admin role required")
return
}
logger.Debug("admin middleware passed",
zap.Uint("user_id", user.ID),
zap.String("user_email", user.Email),
)
next.ServeHTTP(w, r)
})
}
+137
View File
@@ -0,0 +1,137 @@
// middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"api_bb/internal/models"
"api_bb/internal/repository"
"api_bb/internal/service"
"api_bb/pkg/logger"
"go.uber.org/zap"
)
type contextKey string
const (
UserIDKey contextKey = "userID"
UserKey contextKey = "user"
)
func AuthMiddleware(jwtService service.JWTService, userRepo repository.UserRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenString string
logger := logger.Get()
logger.Debug("authMiddleware Start")
// Пробуем получить токен из заголовка Authorization
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
logger.Debug("Token found in Authorization header")
}
// Если нет в заголовке, пробуем из куки
if tokenString == "" {
cookie, err := r.Cookie("auth_token")
if err == nil {
tokenString = cookie.Value
logger.Debug("Token found in auth_token cookie")
} else {
logger.Debug("No auth_token cookie found", zap.Error(err))
}
}
if tokenString == "" {
logger.Debug("No token found in request")
next.ServeHTTP(w, r)
return
}
token, err := jwtService.ValidateToken(tokenString)
if err != nil || !token.Valid {
logger.Warn("Invalid token",
zap.Error(err),
zap.Bool("token_valid", token != nil && token.Valid))
next.ServeHTTP(w, r)
return
}
userID, err := jwtService.ExtractUserID(token)
if err != nil {
logger.Error("Failed to extract user ID from token",
zap.Error(err))
next.ServeHTTP(w, r)
return
}
logger.Debug("Extracted user ID from token",
zap.Any("user_id", userID))
user, err := userRepo.FindByID(userID)
if err != nil {
logger.Error("Failed to find user by ID",
zap.Any("user_id", userID),
zap.Error(err))
next.ServeHTTP(w, r)
return
}
// Добавляем пользователя в контекст
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserKey, user)
logger.Debug("User authenticated successfully",
zap.Any("user_id", userID),
zap.String("username", user.FirstName))
logger.Debug("authMiddleware End")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAuth middleware требует аутентификации
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := logger.Get()
userID := r.Context().Value(UserIDKey)
logger.Debug("RequireAuth method start")
logger.Debug("Extracted user ID from token",
zap.Any("user_id", userID))
if userID == nil {
logger.Warn("Authentication required but no user ID in context")
http.Error(w, `{"error": "Authentication required"}`, http.StatusUnauthorized)
return
}
logger.Debug("User authenticated", zap.Any("user_id", userID))
logger.Debug("authMiddleware End")
next.ServeHTTP(w, r)
})
}
// GetUserFromContext получает пользователя из контекста
func GetUserFromContext(ctx context.Context) (*models.User, bool) {
logger := logger.Get()
user, ok := ctx.Value(UserKey).(*models.User)
logger.Debug("GetUserFromContext method")
logger.Debug("Extracted user ID from token",
zap.Any("user_id", user.ID))
if !ok {
logger.Debug("No user found in context")
} else {
logger.Debug("User retrieved from context",
zap.Any("user_id", user.ID),
zap.String("username", user.FirstName))
}
return user, ok
}
+20
View File
@@ -0,0 +1,20 @@
// pkg/middleware/cors.go
package middleware
import (
"net/http"
"github.com/go-chi/cors"
)
func CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3001", "https://begushiybashkir.ru"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
ExposedHeaders: []string{"Link", "Content-Length"},
AllowCredentials: true,
MaxAge: 300,
})
}
+46
View File
@@ -0,0 +1,46 @@
// pkg/middleware/logger.go
package middleware
import (
"net/http"
"time"
"api_bb/pkg/logger"
"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
)
// Logger middleware для логирования HTTP запросов
func ZapLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Получаем request ID
reqID := middleware.GetReqID(r.Context())
// Создаем логгер с контекстом запроса
requestLogger := logger.Get().With(
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
zap.String("user_agent", r.UserAgent()),
zap.String("request_id", reqID),
)
// Обертываем ResponseWriter для получения статуса
wrappedWriter := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
// Обрабатываем запрос
next.ServeHTTP(wrappedWriter, r)
// Логируем результат
duration := time.Since(start)
requestLogger.Info("request completed",
zap.Int("status", wrappedWriter.Status()),
zap.Int("bytes", wrappedWriter.BytesWritten()),
zap.Duration("duration", duration),
)
})
}
@@ -0,0 +1,35 @@
package middleware
import (
"net/http"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func CommonMiddleware() []func(http.Handler) http.Handler {
return []func(http.Handler) http.Handler{
HandleOptions,
CORS(),
ZapLogger,
middleware.Recoverer,
middleware.RequestID,
cors.Handler(cors.Options{
AllowedOrigins: []string{
"https://xn--80abahjtcfl5d0a8di.xn--p1ai",
"https://begushiybashkir.ru",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:5173"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
ExposedHeaders: []string{
"Link",
"Content-Length",
"Set-Cookie",
},
AllowCredentials: true,
MaxAge: 300,
}),
}
}
+19
View File
@@ -0,0 +1,19 @@
// pkg/middleware/options.go
package middleware
import "net/http"
// HandleOptions автоматически обрабатывает OPTIONS запросы
func HandleOptions(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
+27
View File
@@ -0,0 +1,27 @@
package utils
// formatPace форматирует темп в строку "MM:SS"
func FormatPace(minutes, seconds int) string {
if seconds >= 60 {
minutes += seconds / 60
seconds = seconds % 60
}
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
}
// formatTwoDigits форматирует число в двузначную строку
func FormatTwoDigits(num int) string {
if num < 10 {
return "0" + string(rune(num+'0'))
}
return string(rune(num/10+'0')) + string(rune(num%10+'0'))
}
// formatTime форматирует время в строку "MM:SS"
func FormatTime(minutes, seconds int) string {
if seconds >= 60 {
minutes += seconds / 60
seconds = seconds % 60
}
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
}
+20
View File
@@ -0,0 +1,20 @@
// pkg/utils/response.go (дополнение)
package utils
import (
"encoding/json"
"net/http"
)
// RespondWithValidationError отправляет ответ с ошибками валидации
func RespondWithValidationError(w http.ResponseWriter, validationError error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
response := map[string]interface{}{
"error": "Validation failed",
"details": GetValidationErrors(validationError),
}
json.NewEncoder(w).Encode(response)
}
+75
View File
@@ -0,0 +1,75 @@
package utils
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
RespondWithJSON(w, statusCode, map[string]string{"error": message})
}
// DecodeJSONBody декодирует JSON тело запроса
func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
if r.Header.Get("Content-Type") != "application/json" {
return errors.New("Content-Type header is not application/json")
}
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("request body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("request body contains unknown field %s", fieldName)
case errors.Is(err, io.EOF):
return errors.New("request body must not be empty")
case err.Error() == "http: request body too large":
return errors.New("request body must not be larger than 1MB")
default:
return err
}
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("request body must only contain a single JSON object")
}
return nil
}
// GetUserIDFromContext извлекает userID из контекста
func GetUserIDFromContext(r *http.Request) (uint, bool) {
userID, ok := r.Context().Value("userID").(uint)
return userID, ok
}
+398
View File
@@ -0,0 +1,398 @@
// pkg/utils/validation.go
package utils
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// ValidationError представляет ошибку валидации
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationResult содержит результат валидации
type ValidationResult struct {
IsValid bool
Errors []ValidationError
}
// TagOptions содержит опции из тега validate
type TagOptions struct {
Required bool
Min *float64
Max *float64
MinInt *int64
MaxInt *int64
OneOf []string
Email bool
MaxLength *int
MinLength *int
Custom string
}
// ValidateStruct валидирует структуру на основе тегов validate
func ValidateStruct(s interface{}) error {
val := reflect.ValueOf(s)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return fmt.Errorf("ValidateStruct expects a struct, got %T", s)
}
var errors []ValidationError
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Пропускаем неэкспортируемые поля
if !field.CanInterface() {
continue
}
tag := fieldType.Tag.Get("validate")
if tag == "" {
continue
}
options := parseTagOptions(tag)
fieldName := getFieldName(fieldType)
// Валидация поля
if err := validateField(field, fieldName, options); err != nil {
errors = append(errors, err...)
}
}
if len(errors) > 0 {
return &ValidationResult{
IsValid: false,
Errors: errors,
}
}
return nil
}
// parseTagOptions парсит тег validate и возвращает опции
func parseTagOptions(tag string) TagOptions {
options := TagOptions{}
parts := strings.Split(tag, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
switch {
case part == "required":
options.Required = true
case part == "email":
options.Email = true
case strings.HasPrefix(part, "min="):
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
options.Min = &val
}
case strings.HasPrefix(part, "max="):
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
options.Max = &val
}
case strings.HasPrefix(part, "minint="):
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
options.MinInt = &val
}
case strings.HasPrefix(part, "maxint="):
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
options.MaxInt = &val
}
case strings.HasPrefix(part, "oneof="):
options.OneOf = strings.Split(part[6:], " ")
case strings.HasPrefix(part, "maxlen="):
if val, err := strconv.Atoi(part[7:]); err == nil {
options.MaxLength = &val
}
case strings.HasPrefix(part, "minlen="):
if val, err := strconv.Atoi(part[7:]); err == nil {
options.MinLength = &val
}
case strings.HasPrefix(part, "custom="):
options.Custom = part[7:]
}
}
return options
}
// getFieldName возвращает имя поля для сообщений об ошибках
func getFieldName(field reflect.StructField) string {
jsonTag := field.Tag.Get("json")
if jsonTag != "" {
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
return parts[0]
}
}
return field.Name
}
// validateField валидирует отдельное поле
func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
// Проверка required
if options.Required {
if isEmptyValue(field) {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "field is required",
})
return errors // Если поле обязательно и пустое, дальше не проверяем
}
}
// Если поле пустое и не обязательное, дальше не проверяем
if isEmptyValue(field) {
return errors
}
// Валидация в зависимости от типа поля
switch field.Kind() {
case reflect.String:
errors = append(errors, validateString(field.String(), fieldName, options)...)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
errors = append(errors, validateInt(field.Int(), fieldName, options)...)
case reflect.Float32, reflect.Float64:
errors = append(errors, validateFloat(field.Float(), fieldName, options)...)
case reflect.Struct:
// Для time.Time и других структур
if field.Type().String() == "time.Time" {
errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...)
}
}
return errors
}
// validateString валидирует строковые поля
func validateString(value, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
// Проверка email
if options.Email {
if !isValidEmail(value) {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "invalid email format",
})
}
}
// Проверка длины строки
if options.MinLength != nil && len(value) < *options.MinLength {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength),
})
}
if options.MaxLength != nil && len(value) > *options.MaxLength {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength),
})
}
// Проверка oneof
if len(options.OneOf) > 0 {
valid := false
for _, allowed := range options.OneOf {
if value == allowed {
valid = true
break
}
}
if !valid {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")),
})
}
}
return errors
}
// validateInt валидирует целочисленные поля
func validateInt(value int64, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
if options.MinInt != nil && value < *options.MinInt {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("minimum value is %d", *options.MinInt),
})
}
if options.MaxInt != nil && value > *options.MaxInt {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("maximum value is %d", *options.MaxInt),
})
}
return errors
}
// validateFloat валидирует поля с плавающей точкой
func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
if options.Min != nil && value < *options.Min {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("minimum value is %.2f", *options.Min),
})
}
if options.Max != nil && value > *options.Max {
errors = append(errors, ValidationError{
Field: fieldName,
Message: fmt.Sprintf("maximum value is %.2f", *options.Max),
})
}
return errors
}
// validateTime валидирует временные поля
func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError {
var errors []ValidationError
// Проверка, что дата не нулевая
if value.IsZero() && options.Required {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "date is required",
})
}
// Проверка, что дата не в будущем (пример кастомной валидации)
if options.Custom == "not_future" && value.After(time.Now()) {
errors = append(errors, ValidationError{
Field: fieldName,
Message: "date cannot be in the future",
})
}
return errors
}
// isEmptyValue проверяет, является ли значение пустым
func isEmptyValue(v reflect.Value) bool {
switch v.Kind() {
case reflect.String:
return v.String() == ""
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Struct:
if v.Type().String() == "time.Time" {
return v.Interface().(time.Time).IsZero()
}
case reflect.Ptr, reflect.Interface:
return v.IsNil()
case reflect.Slice, reflect.Map, reflect.Array:
return v.Len() == 0
}
return false
}
// isValidEmail проверяет валидность email
func isValidEmail(email string) bool {
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(emailRegex, email)
return matched
}
// Error возвращает строковое представление ошибок валидации
func (vr *ValidationResult) Error() string {
var errorMessages []string
for _, err := range vr.Errors {
errorMessages = append(errorMessages, err.Error())
}
return strings.Join(errorMessages, "; ")
}
// GetValidationErrors возвращает ошибки валидации в структурированном виде
func GetValidationErrors(err error) []ValidationError {
if vr, ok := err.(*ValidationResult); ok {
return vr.Errors
}
return nil
}
// LogValidationErrors логирует ошибки валидации
func LogValidationErrors(logger *zap.Logger, err error, context string) {
if vr, ok := err.(*ValidationResult); ok {
for _, validationErr := range vr.Errors {
logger.Warn("validation error",
zap.String("context", context),
zap.String("field", validationErr.Field),
zap.String("error", validationErr.Message),
)
}
}
}
// ParseUintFromQuery парсит uint из query параметра
func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) {
if queryParam == "" {
return defaultValue, nil
}
value, err := strconv.ParseUint(queryParam, 10, 32)
if err != nil {
return defaultValue, err
}
return uint(value), nil
}
// ParseIntFromQuery парсит int из query параметра
func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) {
if queryParam == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(queryParam)
if err != nil {
return defaultValue, err
}
return value, nil
}
// ParseBoolFromQuery парсит bool из query параметра
func ParseBoolFromQuery(queryParam string, defaultValue bool) bool {
if queryParam == "" {
return defaultValue
}
return strings.ToLower(queryParam) == "true" || queryParam == "1"
}