diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7743066 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Server +APP_ENV=development +APP_PORT=8080 +APP_URL=http://localhost:8080 + +# PostgreSQL +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_USER=buhapp +POSTGRES_PASSWORD=buhapp_secret +POSTGRES_DB=buhapp +POSTGRES_SSLMODE=disable + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# MinIO +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=buhapp_minio +MINIO_SECRET_KEY=buhapp_minio_secret +MINIO_BUCKET=buhapp +MINIO_USE_SSL=false + +# JWT +JWT_SECRET=change-me-in-production-please-use-long-random-string +JWT_ACCESS_TTL=3600 +JWT_REFRESH_TTL=2592000 + +# Legal documents versions +TERMS_VERSION=1.0 +PRIVACY_VERSION=1.0 +DISCLAIMER_VERSION=1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f9f1f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.env.local +*.log +.git/ +*.exe +*.test +*.out +coverage/ +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f4b49a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +# dependencies +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum* ./ +RUN go mod download || go mod tidy + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /buhapp-api ./cmd/server + +# --- +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -g 1000 buhapp && adduser -D -u 1000 -G buhapp buhapp + +WORKDIR /app +COPY --from=builder /buhapp-api /app/buhapp-api +COPY --from=builder /app/migrations /app/migrations + +USER buhapp +EXPOSE 8080 +ENTRYPOINT ["/app/buhapp-api"] diff --git a/README.md b/README.md index ba85ed4..b1e147b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,52 @@ -# buhapp-backend +# BuhApp Backend -Backend на Go (Fiber + PostgreSQL + Redis + MinIO) \ No newline at end of file +Backend на Go (Fiber) для приложения BuhApp. + +## Стек + +- Go 1.22+ +- Fiber v2 (HTTP framework) +- PostgreSQL 16 (pgx/v5) +- Redis 7 (go-redis) +- MinIO (S3-compatible storage) +- JWT (golang-jwt) +- bcrypt (golang.org/x/crypto) + +## Структура + +``` +buhapp-backend/ +├── cmd/server/main.go — точка входа +├── internal/ +│ ├── config/ — конфигурация (.env) +│ ├── db/ — PostgreSQL, миграции +│ ├── redis/ — Redis клиент +│ ├── storage/ — MinIO клиент +│ ├── auth/ — JWT, регистрация, логин +│ ├── users/ — профили +│ ├── consent/ — лог согласий (юридика) +│ ├── audit/ — audit_log +│ ├── legal/ — версии документов +│ ├── middleware/ — auth, rate limit +│ └── handlers/ — HTTP handlers +├── migrations/ — SQL миграции +├── Dockerfile +├── docker-compose.yml +├── .env.example +└── README.md +``` + +## Запуск (dev) + +```bash +docker compose up -d postgres redis minio +go run cmd/server/main.go +``` + +## API + +См. `/buhapp-docs/TZ.md` для полного списка. + +## Документация + +https://git.buhapp.mygoodservice.ru/ga/buhapp-docs diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..52fc75d --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + + "github.com/buhapp/backend/internal/audit" + "github.com/buhapp/backend/internal/auth" + "github.com/buhapp/backend/internal/config" + "github.com/buhapp/backend/internal/consent" + "github.com/buhapp/backend/internal/db" + "github.com/buhapp/backend/internal/handlers" + "github.com/buhapp/backend/internal/redis" + "github.com/buhapp/backend/internal/storage" + "github.com/buhapp/backend/internal/users" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + ctx := context.Background() + + // PostgreSQL + pg, err := db.New(ctx, cfg.PostgresDSN()) + if err != nil { + log.Fatalf("postgres: %v", err) + } + defer pg.Close() + log.Println("postgres: connected") + + // Run migrations + migDir := os.Getenv("MIGRATIONS_DIR") + if migDir == "" { + migDir = "./migrations" + } + if err := db.RunMigrations(ctx, pg, migDir); err != nil { + log.Fatalf("migrations: %v", err) + } + + // Redis + rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB) + if err != nil { + log.Printf("redis warning: %v", err) + } else { + log.Println("redis: connected") + } + + // MinIO + st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL) + if err != nil { + log.Printf("minio warning: %v", err) + } else { + log.Println("minio: connected") + } + + // Services + usersRepo := users.NewRepo(pg.Pool) + consentRepo := consent.NewRepo(pg.Pool) + auditRepo := audit.NewRepo(pg.Pool) + authSvc := auth.NewService(cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL) + + authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc) + + // Fiber app + app := fiber.New(fiber.Config{ + AppName: "buhapp-api", + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + }) + app.Use(recover.New()) + app.Use(logger.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", + })) + + // Health + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "time": time.Now().UTC().Format(time.RFC3339), + "version": "0.1.0", + "db": pg.Pool != nil, + "redis": rdb != nil, + "storage": st != nil, + }) + }) + + // Legal — публичные версии документов + app.Get("/api/v1/legal/terms", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"version": cfg.TermsVersion, "url": "/legal/TERMS.md"}) + }) + app.Get("/api/v1/legal/privacy", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"version": cfg.PrivacyVersion, "url": "/legal/PRIVACY.md"}) + }) + app.Get("/api/v1/legal/disclaimer", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"}) + }) + + // Auth (public) + api := app.Group("/api/v1") + api.Post("/auth/register", authH.Register) + api.Post("/auth/login", authH.Login) + + // Auth (protected) + protected := api.Group("", auth.Middleware(authSvc)) + protected.Get("/me", authH.Me) + + // Graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("shutdown...") + _ = app.ShutdownWithTimeout(10 * time.Second) + }() + + addr := fmt.Sprintf(":%d", cfg.AppPort) + log.Printf("listening on %s", addr) + if err := app.Listen(addr); err != nil { + log.Fatalf("listen: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cf14ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,72 @@ +services: + postgres: + image: postgres:16-alpine + container_name: buhapp-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-buhapp} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-buhapp_secret} + POSTGRES_DB: ${POSTGRES_DB:-buhapp} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] + interval: 5s + timeout: 3s + retries: 5 + + redis: + image: redis:7-alpine + container_name: buhapp-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + minio: + image: minio/minio:latest + container_name: buhapp-minio + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-buhapp_minio} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-buhapp_minio_secret} + command: ["server", "/data", "--console-address", ":9001"] + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 3s + retries: 5 + + api: + build: + context: . + dockerfile: Dockerfile + container_name: buhapp-api + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_HOST: postgres + REDIS_HOST: redis + MINIO_ENDPOINT: minio:9000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + ports: + - "8080:8080" + +volumes: + postgres_data: + redis_data: + minio_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8244565 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/buhapp/backend + +go 1.22 + +require ( + github.com/gofiber/fiber/v2 v2.52.5 + github.com/gofiber/contrib/jwt v1.0.10 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.1 + github.com/joho/godotenv v1.5.1 + github.com/minio/minio-go/v7 v7.0.77 + github.com/redis/go-redis/v9 v9.7.0 + golang.org/x/crypto v0.27.0 +) diff --git a/internal/audit/repo.go b/internal/audit/repo.go new file mode 100644 index 0000000..4e62bf9 --- /dev/null +++ b/internal/audit/repo.go @@ -0,0 +1,43 @@ +package audit + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Event struct { + ID int64 + UserID *uuid.UUID + Action string + TargetType string + TargetID string + Metadata map[string]any + IP string + UserAgent string + CreatedAt time.Time +} + +type Repo struct { + pool *pgxpool.Pool +} + +func NewRepo(pool *pgxpool.Pool) *Repo { + return &Repo{pool: pool} +} + +func (r *Repo) Log(ctx context.Context, e *Event) error { + meta := []byte("null") + if e.Metadata != nil { + meta = []byte(`{}`) // упрощённо + // NOTE: для prod надо marshal JSON; пропускаем пока + } + return r.pool.QueryRow(ctx, ` + INSERT INTO audit_log (user_id, action, target_type, target_id, metadata, ip, user_agent) + VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::jsonb, NULLIF($6, '')::inet, $7) + RETURNING id, created_at`, + e.UserID, e.Action, e.TargetType, e.TargetID, string(meta), e.IP, e.UserAgent, + ).Scan(&e.ID, &e.CreatedAt) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..9fdfc27 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Claims struct { + UserID uuid.UUID `json:"uid"` + jwt.RegisteredClaims +} + +type Tokens struct { + Access string + Refresh string +} + +type Service struct { + secret []byte + accessTTL time.Duration + refreshTTL time.Duration +} + +func NewService(secret string, accessTTL, refreshTTL time.Duration) *Service { + return &Service{ + secret: []byte(secret), + accessTTL: accessTTL, + refreshTTL: refreshTTL, + } +} + +func (s *Service) Generate(userID uuid.UUID) (*Tokens, error) { + now := time.Now() + + access := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)), + Subject: userID.String(), + Type: "access", + }, + }) + accessStr, err := access.SignedString(s.secret) + if err != nil { + return nil, err + } + + refresh := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(s.refreshTTL)), + Subject: userID.String(), + Type: "refresh", + }, + }) + refreshStr, err := refresh.SignedString(s.secret) + if err != nil { + return nil, err + } + + return &Tokens{Access: accessStr, Refresh: refreshStr}, nil +} + +func (s *Service) Parse(ctx context.Context, tokenStr string) (*Claims, error) { + claims := &Claims{} + tok, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return s.secret, nil + }) + if err != nil || !tok.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..e6390c9 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,40 @@ +package auth + +import ( + "errors" + "strings" + + "github.com/gofiber/fiber/v2" +) + +const UserIDKey = "user_id" + +func Middleware(svc *Service) fiber.Handler { + return func(c *fiber.Ctx) error { + h := c.Get("Authorization") + if h == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing Authorization header"}) + } + parts := strings.SplitN(h, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid Authorization header"}) + } + claims, err := svc.Parse(c.UserContext(), parts[1]) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"}) + } + c.Locals(UserIDKey, claims.UserID) + return c.Next() + } +} + +func UserID(c *fiber.Ctx) (string, error) { + v := c.Locals(UserIDKey) + if v == nil { + return "", errors.New("no user in context") + } + if id, ok := v.(interface{ String() string }); ok { + return id.String(), nil + } + return "", errors.New("invalid user id type") +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1cc6b2f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,120 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" + + "github.com/joho/godotenv" +) + +type Config struct { + AppEnv string + AppPort int + AppURL string + + PostgresHost string + PostgresPort int + PostgresUser string + PostgresPassword string + PostgresDB string + PostgresSSLMode string + + RedisHost string + RedisPort int + RedisPassword string + RedisDB int + + MinIOEndpoint string + MinIOAccessKey string + MinIOSecretKey string + MinIOBucket string + MinIOUseSSL bool + + JWTSecret string + JWTAccessTTL time.Duration + JWTRefreshTTL time.Duration + + TermsVersion string + PrivacyVersion string + DisclaimerVersion string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + cfg := &Config{ + AppEnv: getEnv("APP_ENV", "development"), + AppPort: getEnvInt("APP_PORT", 8080), + AppURL: getEnv("APP_URL", "http://localhost:8080"), + + PostgresHost: getEnv("POSTGRES_HOST", "localhost"), + PostgresPort: getEnvInt("POSTGRES_PORT", 5432), + PostgresUser: getEnv("POSTGRES_USER", "buhapp"), + PostgresPassword: getEnv("POSTGRES_PASSWORD", "buhapp_secret"), + PostgresDB: getEnv("POSTGRES_DB", "buhapp"), + PostgresSSLMode: getEnv("POSTGRES_SSLMODE", "disable"), + + RedisHost: getEnv("REDIS_HOST", "localhost"), + RedisPort: getEnvInt("REDIS_PORT", 6379), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: getEnvInt("REDIS_DB", 0), + + MinIOEndpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), + MinIOAccessKey: getEnv("MINIO_ACCESS_KEY", "buhapp_minio"), + MinIOSecretKey: getEnv("MINIO_SECRET_KEY", "buhapp_minio_secret"), + MinIOBucket: getEnv("MINIO_BUCKET", "buhapp"), + MinIOUseSSL: getEnvBool("MINIO_USE_SSL", false), + + JWTSecret: getEnv("JWT_SECRET", "change-me"), + JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL", 3600)) * time.Second, + JWTRefreshTTL: time.Duration(getEnvInt("JWT_REFRESH_TTL", 2592000)) * time.Second, + + TermsVersion: getEnv("TERMS_VERSION", "1.0"), + PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"), + DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"), + } + + if cfg.JWTSecret == "change-me" { + return nil, fmt.Errorf("JWT_SECRET must be set") + } + + return cfg, nil +} + +func (c *Config) PostgresDSN() string { + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.PostgresUser, c.PostgresPassword, + c.PostgresHost, c.PostgresPort, + c.PostgresDB, c.PostgresSSLMode) +} + +func (c *Config) RedisAddr() string { + return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort) +} + +func getEnv(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +func getEnvInt(key string, def int) int { + if v, ok := os.LookupEnv(key); ok { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func getEnvBool(key string, def bool) bool { + if v, ok := os.LookupEnv(key); ok { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return def +} diff --git a/internal/consent/repo.go b/internal/consent/repo.go new file mode 100644 index 0000000..c7e47ae --- /dev/null +++ b/internal/consent/repo.go @@ -0,0 +1,56 @@ +package consent + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type DocType string + +const ( + DocTerms DocType = "terms" + DocPrivacy DocType = "privacy" + DocDisclaimer DocType = "disclaimer" + DocAdult DocType = "adult" +) + +type Consent struct { + ID int64 + UserID uuid.UUID + DocType DocType + DocVersion string + IP string + UserAgent string + AcceptedAt time.Time +} + +type Repo struct { + pool *pgxpool.Pool +} + +func NewRepo(pool *pgxpool.Pool) *Repo { + return &Repo{pool: pool} +} + +func (r *Repo) Record(ctx context.Context, c *Consent) error { + return r.pool.QueryRow(ctx, ` + INSERT INTO consent_log (user_id, doc_type, doc_version, ip, user_agent) + VALUES ($1, $2, $3, NULLIF($4, '')::inet, $5) + RETURNING id, accepted_at`, + c.UserID, c.DocType, c.DocVersion, c.IP, c.UserAgent, + ).Scan(&c.ID, &c.AcceptedAt) +} + +func (r *Repo) HasAccepted(ctx context.Context, userID uuid.UUID, docType DocType, version string) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM consent_log + WHERE user_id=$1 AND doc_type=$2 AND doc_version=$3 + )`, userID, docType, version, + ).Scan(&exists) + return exists, err +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..5bafcdd --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +func RunMigrations(ctx context.Context, db *DB, dir string) error { + files, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read migrations dir: %w", err) + } + + var ups []string + for _, f := range files { + if !f.IsDir() && strings.HasSuffix(f.Name(), ".up.sql") { + ups = append(ups, f.Name()) + } + } + sort.Strings(ups) + + for _, name := range ups { + path := filepath.Join(dir, name) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + if _, err := db.Pool.Exec(ctx, string(data)); err != nil { + return fmt.Errorf("exec %s: %w", name, err) + } + fmt.Printf("[migrate] applied %s\n", name) + } + return nil +} diff --git a/internal/db/postgres.go b/internal/db/postgres.go new file mode 100644 index 0000000..624248d --- /dev/null +++ b/internal/db/postgres.go @@ -0,0 +1,37 @@ +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func New(ctx context.Context, dsn string) (*DB, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 25 + cfg.MinConns = 2 + cfg.MaxConnLifetime = 5 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + return nil, err + } + + return &DB{Pool: pool}, nil +} + +func (d *DB) Close() { + d.Pool.Close() +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..50d0a28 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,240 @@ +package handlers + +import ( + "regexp" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/buhapp/backend/internal/audit" + "github.com/buhapp/backend/internal/auth" + "github.com/buhapp/backend/internal/config" + "github.com/buhapp/backend/internal/consent" + "github.com/buhapp/backend/internal/users" +) + +type AuthHandler struct { + cfg *config.Config + users *users.Repo + consent *consent.Repo + audit *audit.Repo + auth *auth.Service +} + +func NewAuthHandler(cfg *config.Config, u *users.Repo, c *consent.Repo, a *audit.Repo, au *auth.Service) *AuthHandler { + return &AuthHandler{cfg: cfg, users: u, consent: c, audit: a, auth: au} +} + +type registerRequest struct { + Email string `json:"email"` + Phone string `json:"phone"` + Password string `json:"password"` + Name string `json:"name"` + Birthdate string `json:"birthdate"` + Gender string `json:"gender"` + City string `json:"city"` + Consents consentsBlock `json:"consents"` +} + +type consentsBlock struct { + Adult bool `json:"adult"` + Terms bool `json:"terms"` + Privacy bool `json:"privacy"` + Disclaimer bool `json:"disclaimer"` +} + +var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) +var phoneRe = regexp.MustCompile(`^\+?[0-9]{10,15}$`) + +func (h *AuthHandler) Register(c *fiber.Ctx) error { + var req registerRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"}) + } + + // validate required consents + if !req.Consents.Adult || !req.Consents.Terms || !req.Consents.Privacy || !req.Consents.Disclaimer { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "all consents required: adult, terms, privacy, disclaimer", + }) + } + + // normalize + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + req.Phone = strings.TrimSpace(req.Phone) + req.Name = strings.TrimSpace(req.Name) + + if req.Email == "" && req.Phone == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email or phone required"}) + } + if req.Email != "" && !emailRe.MatchString(req.Email) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email"}) + } + if req.Phone != "" && !phoneRe.MatchString(req.Phone) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid phone"}) + } + if len(req.Password) < 8 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password must be at least 8 chars"}) + } + if req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"}) + } + + var birth *time.Time + if req.Birthdate != "" { + t, err := time.Parse("2006-01-02", req.Birthdate) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "birthdate must be YYYY-MM-DD"}) + } + if t.After(time.Now().AddDate(-18, 0, 0)) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "must be 18+"}) + } + birth = &t + } + + if req.Gender != "" && req.Gender != "m" && req.Gender != "f" && req.Gender != "o" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "gender must be m, f or o"}) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "hash failed"}) + } + + u := &users.User{ + Email: req.Email, + Phone: req.Phone, + Name: req.Name, + Birthdate: birth, + Gender: req.Gender, + City: req.City, + } + if err := h.users.CreateWithPassword(c.UserContext(), u, string(hash)); err != nil { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "user already exists or db error"}) + } + + ip := c.IP() + ua := c.Get("User-Agent") + + // log consents + for _, item := range []struct { + Type consent.DocType + Ver string + }{ + {consent.DocAdult, "1.0"}, + {consent.DocTerms, h.cfg.TermsVersion}, + {consent.DocPrivacy, h.cfg.PrivacyVersion}, + {consent.DocDisclaimer, h.cfg.DisclaimerVersion}, + } { + _ = h.consent.Record(c.UserContext(), &consent.Consent{ + UserID: u.ID, DocType: item.Type, DocVersion: item.Ver, + IP: ip, UserAgent: ua, + }) + } + + // audit + uid := u.ID + _ = h.audit.Log(c.UserContext(), &audit.Event{ + UserID: &uid, Action: "user.register", + IP: ip, UserAgent: ua, + }) + + tokens, err := h.auth.Generate(u.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token failed"}) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "user": fiber.Map{ + "id": u.ID, + "email": u.Email, + "phone": u.Phone, + "name": u.Name, + "city": u.City, + "gender": u.Gender, + "birthdate": u.Birthdate, + }, + "tokens": tokens, + }) +} + +type loginRequest struct { + Email string `json:"email"` + Phone string `json:"phone"` + Password string `json:"password"` +} + +func (h *AuthHandler) Login(c *fiber.Ctx) error { + var req loginRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"}) + } + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + req.Phone = strings.TrimSpace(req.Phone) + if req.Email == "" && req.Phone == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email or phone required"}) + } + if len(req.Password) < 1 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password required"}) + } + + var u *users.User + var hash string + var err error + if req.Email != "" { + u, hash, err = h.users.GetByEmail(c.UserContext(), req.Email) + } else { + // MVP: поиск по телефону пока не реализован — добавим позже + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "login by phone not implemented yet"}) + } + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"}) + } + if u == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"}) + } + if u.IsBlocked { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "account blocked"}) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"}) + } + + tokens, err := h.auth.Generate(u.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token failed"}) + } + + uid := u.ID + _ = h.audit.Log(c.UserContext(), &audit.Event{ + UserID: &uid, Action: "user.login", + IP: c.IP(), UserAgent: c.Get("User-Agent"), + }) + + return c.JSON(fiber.Map{ + "user": u, + "tokens": tokens, + }) +} + +func (h *AuthHandler) Me(c *fiber.Ctx) error { + idStr, err := auth.UserID(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"}) + } + id, err := uuid.Parse(idStr) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "bad user id"}) + } + u, err := h.users.GetByID(c.UserContext(), id) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"}) + } + if u == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.JSON(u) +} diff --git a/internal/redis/redis.go b/internal/redis/redis.go new file mode 100644 index 0000000..2073152 --- /dev/null +++ b/internal/redis/redis.go @@ -0,0 +1,23 @@ +package redis + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +func New(ctx context.Context, addr, password string, db int) (*redis.Client, error) { + c := redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + DialTimeout: 3 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }) + if err := c.Ping(ctx).Err(); err != nil { + return nil, err + } + return c, nil +} diff --git a/internal/storage/minio.go b/internal/storage/minio.go new file mode 100644 index 0000000..9f94864 --- /dev/null +++ b/internal/storage/minio.go @@ -0,0 +1,36 @@ +package storage + +import ( + "context" + "fmt" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type Storage struct { + Client *minio.Client + Bucket string +} + +func New(ctx context.Context, endpoint, accessKey, secretKey, bucket string, useSSL bool) (*Storage, error) { + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: useSSL, + }) + if err != nil { + return nil, fmt.Errorf("minio: %w", err) + } + + exists, err := client.BucketExists(ctx, bucket) + if err != nil { + return nil, fmt.Errorf("bucket exists check: %w", err) + } + if !exists { + if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { + return nil, fmt.Errorf("make bucket: %w", err) + } + } + + return &Storage{Client: client, Bucket: bucket}, nil +} diff --git a/internal/users/repo.go b/internal/users/repo.go new file mode 100644 index 0000000..cb6d312 --- /dev/null +++ b/internal/users/repo.go @@ -0,0 +1,103 @@ +package users + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type User struct { + ID uuid.UUID + Email string + Phone string + Name string + Birthdate *time.Time + Gender string + City string + Bio string + PhotoURL string + IsVerified bool + IsBlocked bool + CreatedAt time.Time + UpdatedAt time.Time + LastSeenAt *time.Time +} + +type Repo struct { + pool *pgxpool.Pool +} + +func NewRepo(pool *pgxpool.Pool) *Repo { + return &Repo{pool: pool} +} + +func (r *Repo) CreateWithPassword(ctx context.Context, u *User, passwordHash string) error { + return r.pool.QueryRow(ctx, ` + INSERT INTO users (email, phone, password_hash, name, birthdate, gender, city) + VALUES (NULLIF($1, ''), NULLIF($2, ''), $3, $4, $5, NULLIF($6, ''), NULLIF($7, '')) + RETURNING id, created_at, updated_at`, + u.Email, u.Phone, passwordHash, u.Name, u.Birthdate, u.Gender, u.City, + ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) +} + +func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { + u := &User{} + var email, phone, gender, city, bio, photo *string + err := r.pool.QueryRow(ctx, ` + SELECT id, email, phone, name, birthdate, gender, city, bio, photo_url, + is_verified, is_blocked, created_at, updated_at, last_seen_at + FROM users WHERE id=$1`, id, + ).Scan(&u.ID, &email, &phone, &u.Name, &u.Birthdate, &gender, &city, &bio, &photo, + &u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if email != nil { + u.Email = *email + } + if phone != nil { + u.Phone = *phone + } + if gender != nil { + u.Gender = *gender + } + if city != nil { + u.City = *city + } + if bio != nil { + u.Bio = *bio + } + if photo != nil { + u.PhotoURL = *photo + } + return u, nil +} + +func (r *Repo) GetByEmail(ctx context.Context, email string) (*User, string, error) { + u := &User{} + var hash string + var em *string + err := r.pool.QueryRow(ctx, ` + SELECT id, email, password_hash, name, birthdate, gender, city, bio, photo_url, + is_verified, is_blocked, created_at, updated_at, last_seen_at + FROM users WHERE email=$1`, email, + ).Scan(&u.ID, &em, &hash, &u.Name, &u.Birthdate, &u.Gender, &u.City, &u.Bio, &u.PhotoURL, + &u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + if em != nil { + u.Email = *em + } + return u, hash, nil +} diff --git a/migrations/0001_init.down.sql b/migrations/0001_init.down.sql new file mode 100644 index 0000000..52f7a93 --- /dev/null +++ b/migrations/0001_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS consent_log; +DROP TABLE IF EXISTS users; diff --git a/migrations/0001_init.up.sql b/migrations/0001_init.up.sql new file mode 100644 index 0000000..213dced --- /dev/null +++ b/migrations/0001_init.up.sql @@ -0,0 +1,56 @@ +-- BuhApp database schema +-- Sprint 1: users, consents, audit + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- USERS +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT UNIQUE, + phone TEXT UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + birthdate DATE, + gender TEXT CHECK (gender IN ('m','f','o')), + city TEXT, + bio TEXT, + photo_url TEXT, + is_verified BOOLEAN DEFAULT FALSE, + is_blocked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_users_last_seen ON users(last_seen_at); + +-- CONSENT LOG (юр. документы, согласия при регистрации) +CREATE TABLE IF NOT EXISTS consent_log ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + doc_type TEXT NOT NULL CHECK (doc_type IN ('terms','privacy','disclaimer','adult')), + doc_version TEXT NOT NULL, + ip INET, + user_agent TEXT, + accepted_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_consent_user ON consent_log(user_id); + +-- AUDIT LOG (журнал действий для разборов) +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + metadata JSONB, + ip INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);