Files
logiflow/internal/handler/server_impl.go

227 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/config"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/rs/xid"
slogchi "github.com/samber/slog-chi"
)
type ctxKey string
const (
requestIDKey ctxKey = "X-Request-ID"
tokenKey ctxKey = "Authorization"
)
type responseOptions struct {
respType string
requestID string
}
type Server struct {
DB *pgxpool.Pool
Config *config.Config
ctx context.Context
Redis *redis.Client
JwtKey []byte
}
func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server {
return &Server{
DB: db,
Redis: redis,
ctx: context.Background(),
Config: cfg,
JwtKey: []byte(cfg.JwtOpt.Key),
}
}
func (s *Server) Run() error {
r := chi.NewMux()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(slogchi.NewWithConfig(slog.Default(), slogchi.Config{
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn, // 400499 → Warn
ServerErrorLevel: slog.LevelError, // 500+ → Error
WithRequestID: true, // берёт request-id из контекста
Filters: []slogchi.Filter{
slogchi.IgnorePath("/health", "/metrics", "/favicon.ico"),
},
}))
r.Use(s.AuthMiddleware)
r.Use(s.MiddlewareRequestID)
h := api.HandlerFromMux(s, r)
srv := &http.Server{
Handler: h,
Addr: s.Config.ServerURL(),
ReadTimeout: s.Config.ReadTimeout(),
IdleTimeout: s.Config.IdleTimeout(),
WriteTimeout: s.Config.WriteTimeout(),
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP сервер упал", "error", err)
}
}()
slog.Info("Приложение запущено успешно ", slog.String("URL", s.Config.ServerURL()))
<-s.ctx.Done()
slog.Info("Остановка HTTP сервера...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
return srv.Shutdown(shutdownCtx)
}
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/register" || r.URL.Path == "/auth/login" {
next.ServeHTTP(w, r)
return
}
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "missing token", "error")
return
}
slog.Info("Проверка авторизации")
claims, err := s.validateAccessToken(r.Context(), tokenStr)
if err != nil {
slog.WarnContext(r.Context(), "Токен не прошел валидацию", slog.String("Token", tokenStr), slog.String("error", err.Error()))
return
}
key := "access_hash:" + tokenStr
if _, err := s.Redis.Get(r.Context(), key).Result(); err == redis.Nil {
s.JSON(w, r, http.StatusUnauthorized, "token revoked or expired", "error")
}
ctx := context.WithValue(r.Context(), "user", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) MiddlewareRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-ID")
if rid == "" {
rid = xid.New().String()
}
ctx := context.WithValue(r.Context(), requestIDKey, rid)
w.Header().Set("X-Request-ID", rid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) JSON(w http.ResponseWriter, r *http.Request, status int, payload any, respType string) {
options := responseOptions{
respType: respType,
requestID: extractRequestID(r),
}
success := status >= 200 && status < 300
resp := api.ApiResponse{
RequestID: &options.requestID,
Status: &status,
Success: &success,
Data: &map[string]interface{}{respType: payload},
}
w.Header().Set("Content-Type", "applicaton/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.ErrorContext(r.Context(), "json encode failed after header written",
slog.Int("status", status),
slog.String("error", err.Error()),
slog.String("request_id", options.requestID),
)
}
}
func extractRequestID(r *http.Request) string {
if r == nil {
return ""
}
if v := r.Context().Value(requestIDKey); v != nil {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
func (s *Server) validateAccessToken(ctx context.Context, tokenStr string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
slog.WarnContext(ctx, "неизвестный алгоритм", "alg", t.Header["alg"])
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return s.JwtKey, nil
})
if err != nil {
return nil, fmt.Errorf("token parse error: %w", err)
}
if !token.Valid {
return nil, errors.New("invalid token")
}
if token.Header["alg"] != "HS256" {
return nil, errors.New("only HS256 allowed")
}
redisKey := "access_hash:" + tokenStr
if _, err := s.Redis.Get(ctx, redisKey).Result(); err != nil {
slog.ErrorContext(ctx, "redis error during token validation", "error", err.Error())
return nil, fmt.Errorf("redis error: %w", err)
}
if claims.TokenID == "" {
return nil, errors.New("missing token id (jti)")
}
if claims.ID == uuid.Nil {
return nil, errors.New("missing user id in claims")
}
return claims, nil
}