Merge pull request #2 from anxi0uz/feature/back-02

Added dockerfile, docker compose, created server implementation with …
This commit is contained in:
2026-03-16 14:29:37 +05:00
committed by GitHub
10 changed files with 451 additions and 2 deletions

2
.env
View File

@@ -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
GOOSE_TABLE=goose_migrations

25
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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("Приложение остановлено")
}

11
configs/prometheus.yml Normal file
View File

@@ -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"]

8
go.mod
View File

@@ -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

14
go.sum
View File

@@ -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=

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)

View File

@@ -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, // 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
}

26
internal/handler/user.go Normal file
View File

@@ -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) {}

98
podman-compose.yml Normal file
View File

@@ -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: