From 2885a596932a452f068ea18a41f09d9ee5a2f0a2 Mon Sep 17 00:00:00 2001 From: anxi0uz Date: Mon, 16 Mar 2026 14:24:26 +0500 Subject: [PATCH] Added dockerfile, docker compose, created server implementation with some middlewares --- .env | 2 +- Dockerfile | 25 ++++ cmd/main.go | 42 ++++++ configs/prometheus.yml | 11 ++ go.mod | 8 +- go.sum | 14 ++ internal/database/postgres.go | 1 + internal/handler/server_impl.go | 226 ++++++++++++++++++++++++++++++++ internal/handler/user.go | 26 ++++ podman-compose.yml | 98 ++++++++++++++ 10 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 configs/prometheus.yml create mode 100644 internal/handler/user.go create mode 100644 podman-compose.yml diff --git a/.env b/.env index 48d0dc9..a61a70f 100644 --- a/.env +++ b/.env @@ -12,4 +12,4 @@ LOGIFLOW_JWT_KEY=98c5772ae16aaa4fd0013eb338252a93b198fb40e9337506334b3aeb21abbe4 GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://${LOGIFLOW_DATABASE_USER}:${LOGIFLOW_DATABASE_PASSWORD}@localhost:${LOGIFLOW_DATABASE_PORT}/${LOGIFLOW_DATABASE_NAME}?sslmode=disable GOOSE_MIGRATION_DIR=./migrations -GOOSE_TABLE=goose_migrations \ No newline at end of file +GOOSE_TABLE=goose_migrations diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f40c6a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM docker.io/library/golang:1.25-alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/logiflow ./cmd/main.go + +FROM docker.io/library/alpine:3.20 + +RUN addgroup -g 1000 app && adduser -u 1000 -G app -D -H -s /sbin/nologin app + +COPY --from=builder /bin/logiflow /app/logiflow +COPY --chown=app:app configs/ /app/configs/ +COPY --chown=app:app migrations/ /app/migrations/ + +USER app + +WORKDIR /app + +EXPOSE 3001 + +CMD ["/app/logiflow"] diff --git a/cmd/main.go b/cmd/main.go index a97b08c..847ebb6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,8 +4,12 @@ import ( "context" "log/slog" "os" + "os/signal" + "syscall" "github.com/anxi0uz/logiflow/internal/config" + "github.com/anxi0uz/logiflow/internal/database" + "github.com/anxi0uz/logiflow/internal/handler" "github.com/golang-cz/devslog" ) @@ -34,7 +38,45 @@ func main() { cfg, err := config.NewConfig(ctx, "configs/config.toml") if err != nil { slog.ErrorContext(ctx, "Cant load configs", slog.String("Error", err.Error())) + os.Exit(1) } slog.SetLogLoggerLevel(cfg.Logiflow.LogLevel) + connectionPool, err := database.NewConnectionPool(ctx, cfg.DatabaseURL()) + if err != nil { + slog.ErrorContext(ctx, "Ошибка подключения к postgres", slog.String("error", err.Error())) + os.Exit(1) + } + defer connectionPool.Close() + + redis, err := database.NewRedisConnection(ctx, cfg) + if err != nil { + slog.ErrorContext(ctx, "Ошибка подключения к redis", slog.String("error", err.Error())) + os.Exit(1) + } + defer redis.Close() + + if err := database.RunMigrations(ctx, cfg.DatabaseURL()); err != nil { + slog.ErrorContext(ctx, "Ошибка миграций", slog.String("error", err.Error())) + os.Exit(1) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + serverErr := make(chan error, 1) + go func() { + serverErr <- handler.NewServer(connectionPool, redis, cfg).Run() + }() + + select { + case sig := <-sigChan: + slog.Info("Получен сигнал завершения", "signal", sig.String()) + case err := <-serverErr: + slog.Error("Сервер упал", "error", err.Error()) + os.Exit(1) + } + cancel() + + slog.Info("Приложение остановлено") } diff --git a/configs/prometheus.yml b/configs/prometheus.yml new file mode 100644 index 0000000..433139b --- /dev/null +++ b/configs/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + - job_name: "handbooks" + static_configs: + - targets: ["app:3001"] diff --git a/go.mod b/go.mod index 28adfba..d534557 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.25.7 require ( github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 github.com/golang-cz/devslog v0.0.15 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/huandu/go-sqlbuilder v1.39.1 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 @@ -15,6 +18,8 @@ require ( github.com/oapi-codegen/runtime v1.2.0 github.com/pressly/goose/v3 v3.27.0 github.com/redis/go-redis/v9 v9.18.0 + github.com/rs/xid v1.6.0 + github.com/samber/slog-chi v1.19.0 ) require ( @@ -22,7 +27,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/huandu/go-clone v1.7.3 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -34,6 +38,8 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 131a1cd..a4e227b 100644 --- a/go.sum +++ b/go.sum @@ -15,10 +15,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-cz/devslog v0.0.15 h1:ejoBLTCwJHWGbAmDf2fyTJJQO3AkzcPjw8SC9LaOQMI= github.com/golang-cz/devslog v0.0.15/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= @@ -74,6 +80,10 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/samber/slog-chi v1.19.0 h1:fl4qH5Hhk7feHtyp4CxJUt7U1TqjPrZ1uueDW9D+Cps= +github.com/samber/slog-chi v1.19.0/go.mod h1:a1iIuofF2gS1ii8aXIQhC6TEguLOhOvSM958fY5hToU= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -84,6 +94,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 5ebebcc..f0f75ac 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -7,6 +7,7 @@ import ( "log/slog" "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" ) diff --git a/internal/handler/server_impl.go b/internal/handler/server_impl.go index e69de29..7bedc63 100644 --- a/internal/handler/server_impl.go +++ b/internal/handler/server_impl.go @@ -0,0 +1,226 @@ +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, // 400–499 → 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 +} diff --git a/internal/handler/user.go b/internal/handler/user.go new file mode 100644 index 0000000..edbe2dc --- /dev/null +++ b/internal/handler/user.go @@ -0,0 +1,26 @@ +package handler + +import ( + "net/http" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Claims struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + TokenID string `json:"token_id"` + jwt.RegisteredClaims +} + +func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) { + +} +func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {} +func (s *Server) AuthRefresh(w http.ResponseWriter, r *http.Request) {} +func (s *Server) AuthRegister(w http.ResponseWriter, r *http.Request) {} +func (s *Server) DeleteMe(w http.ResponseWriter, r *http.Request) {} +func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {} +func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {} diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..d1985d7 --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,98 @@ +networks: + default: + name: LogiflowNetwork + external: true + +services: + postgres: + networks: + - default + image: docker.io/library/postgres:latest + restart: always + container_name: logiflow-postgres + env_file: + - .env + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql + - ./migrations:/docker-entrypoint-initdb.d + environment: + POSTGRES_PASSWORD: ${LOGIFLOW_DATABASE_PASSWORD} + POSTGRES_USER: ${LOGIFLOW_DATABASE_USER} + POSTGRES_DB: ${LOGIFLOW_DATABASE_NAME} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${LOGIFLOW_DATABASE_USER}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + networks: + - default + image: docker.io/library/redis:7-alpine + container_name: logiflow-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --requirepass ${LOGIFLOW_REDIS_PASSWORD} + env_file: + - .env + healthcheck: + test: ["CMD", "redis-cli", "-a", "${LOGIFLOW_REDIS_PASSWORD}", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + prometheus: + networks: + - default + image: docker.io/prom/prometheus:latest + container_name: logiflow-prometheus + ports: + - "9090:9090" + volumes: + - ./configs/prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + + grafana: + networks: + - default + image: docker.io/grafana/grafana:latest + container_name: logiflow-grafana + ports: + - "3000:3000" + env_file: + - .env + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - grafana_data:/var/lib/grafana + - ./configs/datasources:/etc/grafana/provisioning/datasources:ro + depends_on: + - prometheus + + app: + networks: + - default + build: + context: . + dockerfile: Dockerfile + container_name: logiflow-app + ports: + - "3001:3001" + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + postgres_data: + redis_data: + grafana_data: