Sprint 1: scaffold backend (Go + Fiber + PG + Redis + MinIO)
- POST /api/v1/auth/register with mandatory consents
- POST /api/v1/auth/login
- GET /api/v1/me (protected)
- GET /api/v1/legal/{terms,privacy,disclaimer}
- Migrations for users, consent_log, audit_log
- bcrypt + JWT (access + refresh)
- Docker Compose stack
This commit is contained in:
parent
72a24ab492
commit
8d754f9834
35
.env.example
Normal file
35
.env.example
Normal file
@ -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
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.git/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -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"]
|
||||||
53
README.md
53
README.md
@ -1,3 +1,52 @@
|
|||||||
# buhapp-backend
|
# BuhApp Backend
|
||||||
|
|
||||||
Backend на Go (Fiber + PostgreSQL + Redis + MinIO)
|
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
|
||||||
|
|||||||
137
cmd/server/main.go
Normal file
137
cmd/server/main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
docker-compose.yml
Normal file
72
docker-compose.yml
Normal file
@ -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:
|
||||||
15
go.mod
Normal file
15
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
43
internal/audit/repo.go
Normal file
43
internal/audit/repo.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
82
internal/auth/jwt.go
Normal file
82
internal/auth/jwt.go
Normal file
@ -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
|
||||||
|
}
|
||||||
40
internal/auth/middleware.go
Normal file
40
internal/auth/middleware.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
120
internal/config/config.go
Normal file
120
internal/config/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
56
internal/consent/repo.go
Normal file
56
internal/consent/repo.go
Normal file
@ -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
|
||||||
|
}
|
||||||
38
internal/db/migrate.go
Normal file
38
internal/db/migrate.go
Normal file
@ -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
|
||||||
|
}
|
||||||
37
internal/db/postgres.go
Normal file
37
internal/db/postgres.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
240
internal/handlers/auth.go
Normal file
240
internal/handlers/auth.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
23
internal/redis/redis.go
Normal file
23
internal/redis/redis.go
Normal file
@ -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
|
||||||
|
}
|
||||||
36
internal/storage/minio.go
Normal file
36
internal/storage/minio.go
Normal file
@ -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
|
||||||
|
}
|
||||||
103
internal/users/repo.go
Normal file
103
internal/users/repo.go
Normal file
@ -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
|
||||||
|
}
|
||||||
3
migrations/0001_init.down.sql
Normal file
3
migrations/0001_init.down.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS audit_log;
|
||||||
|
DROP TABLE IF EXISTS consent_log;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
56
migrations/0001_init.up.sql
Normal file
56
migrations/0001_init.up.sql
Normal file
@ -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);
|
||||||
Loading…
Reference in New Issue
Block a user