Преглед на файлове

P0: Security & production readiness

- Refresh tokens in DB with rotation (auth.go, refresh_tokens.go, handlers/auth.go)
- Redis rate limiter with ulule/limiter (ratelimit_redis.go, main.go)
- HttpOnly cookies for refresh token (auth handlers + frontend api.ts)
- Entrypoint with migrations (Dockerfile, docker-entrypoint.sh, docker-compose)
- Secrets management: 1Password/Vault/AWS/local + Docker Swarm secrets (deploy.sh, config.go)
- Dependabot config, healthchecks, Makefile test targets
- Obsidian Zettelkasten notes (11 files)

P1 started: validation library (validator.go)
neyrogovnarik преди 1 месец
родител
ревизия
8ac67d5043
променени са 38 файла, в които са добавени 2705 реда и са изтрити 202 реда
  1. 62 0
      .github/dependabot.yml
  2. 2 0
      .gitignore
  3. 17 1
      Makefile
  4. 1 1
      backend/.env.example
  5. 5 2
      backend/Dockerfile
  6. 51 29
      backend/cmd/api/main.go
  7. 31 0
      backend/docker-entrypoint.sh
  8. 2 0
      backend/go.mod
  9. 42 6
      backend/internal/config/config.go
  10. 63 8
      backend/internal/handlers/auth.go
  11. 122 0
      backend/internal/middleware/ratelimit_redis.go
  12. 98 0
      backend/internal/repository/refresh_tokens.go
  13. 83 54
      backend/internal/services/auth.go
  14. 117 0
      backend/internal/validator/validator.go
  15. 124 0
      deploy/README.md
  16. 108 49
      deploy/deploy.sh
  17. 68 23
      deploy/docker-compose.prod.yml
  18. 27 0
      deploy/env.prod.example
  19. 28 3
      docker-compose.yml
  20. 12 22
      frontend/src/hooks/useAuth.tsx
  21. 46 4
      frontend/src/lib/api.ts
  22. 1 0
      obsidian_data/Photoplaces_data/.obsidian/app.json
  23. 1 0
      obsidian_data/Photoplaces_data/.obsidian/appearance.json
  24. 33 0
      obsidian_data/Photoplaces_data/.obsidian/core-plugins.json
  25. 22 0
      obsidian_data/Photoplaces_data/.obsidian/graph.json
  26. 208 0
      obsidian_data/Photoplaces_data/.obsidian/workspace.json
  27. 5 0
      obsidian_data/Photoplaces_data/.trash/Добро пожаловать.md
  28. 56 0
      obsidian_data/Photoplaces_data/architecture-overview.md
  29. 64 0
      obsidian_data/Photoplaces_data/backend-auth-security.md
  30. 79 0
      obsidian_data/Photoplaces_data/backend-rate-limiting.md
  31. 142 0
      obsidian_data/Photoplaces_data/backend-validation.md
  32. 131 0
      obsidian_data/Photoplaces_data/database-migrations.md
  33. 88 0
      obsidian_data/Photoplaces_data/decision-jwt-refresh-storage.md
  34. 153 0
      obsidian_data/Photoplaces_data/decision-rate-limiter-redis.md
  35. 194 0
      obsidian_data/Photoplaces_data/decision-test-strategy.md
  36. 110 0
      obsidian_data/Photoplaces_data/decision-validation-library.md
  37. 163 0
      obsidian_data/Photoplaces_data/deploy-production-readiness.md
  38. 146 0
      obsidian_data/Photoplaces_data/frontend-api-client.md

+ 62 - 0
.github/dependabot.yml

@@ -0,0 +1,62 @@
+# Dependabot configuration for automated dependency updates
+# See: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+  # Go dependencies (backend)
+  - package-ecosystem: "gomod"
+    directory: "/backend"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "09:00"
+    labels:
+      - "dependencies"
+      - "go"
+      - "backend"
+    commit-message:
+      prefix: "deps(backend):"
+    open-pull-requests-limit: 10
+
+  # npm dependencies (frontend)
+  - package-ecosystem: "npm"
+    directory: "/frontend"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "09:00"
+    labels:
+      - "dependencies"
+      - "npm"
+      - "frontend"
+    commit-message:
+      prefix: "deps(frontend):"
+    open-pull-requests-limit: 10
+
+  # Docker base images
+  - package-ecosystem: "docker"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "09:00"
+    labels:
+      - "dependencies"
+      - "docker"
+    commit-message:
+      prefix: "deps(docker):"
+    open-pull-requests-limit: 5
+
+  # GitHub Actions
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "09:00"
+    labels:
+      - "dependencies"
+      - "github-actions"
+    commit-message:
+      prefix: "deps(actions):"
+    open-pull-requests-limit: 5

+ 2 - 0
.gitignore

@@ -14,6 +14,8 @@ out/
 .env
 .env.local
 .env.*.local
+deploy/env.prod
+deploy/*.env
 
 # IDE
 .vscode/

+ 17 - 1
Makefile

@@ -1,4 +1,4 @@
-.PHONY: dev prod deploy logs stop clean help
+.PHONY: dev prod deploy logs stop clean restart ps help test test-backend test-frontend migrate migrate-down
 
 dev:             ## Запустить локальную разработку
 	docker compose up -d
@@ -27,5 +27,21 @@ restart:         ## Перезапустить продакшен
 ps:              ## Статус контейнеров
 	docker compose -f deploy/docker-compose.prod.yml ps
 
+test:            ## Запустить все тесты
+	$(MAKE) test-backend
+	$(MAKE) test-frontend
+
+test-backend:    ## Запустить тесты бэкенда
+	cd backend && go test ./... -v -race
+
+test-frontend:   ## Запустить тесты фронтенда
+	cd frontend && npm run test 2>/dev/null || echo "No test script configured yet"
+
+migrate:         ## Накатить миграции (локально)
+	docker compose run --rm migrate
+
+migrate-down:    ## Откатить последнюю миграцию (локально)
+	docker compose run --rm migrate migrate -path=/migrations -database "postgres://photoplaces:photoplaces_dev@postgres:5432/photoplaces?sslmode=disable" down 1
+
 help:            ## Показать помощь
 	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'

+ 1 - 1
backend/.env.example

@@ -1,6 +1,6 @@
 APP_ENV=development
 DATABASE_URL=postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?sslmode=disable
-REDIS_URL=redis://localhost:6379/0
+REDIS_URL=redis://:photoplaces_dev@localhost:6379/0
 
 S3_ENDPOINT=http://localhost:9000
 S3_PUBLIC_ENDPOINT=http://localhost:9000

+ 5 - 2
backend/Dockerfile

@@ -7,8 +7,11 @@ COPY . .
 RUN CGO_ENABLED=0 GOOS=linux go build -o /photoplaces-api ./cmd/api
 
 FROM alpine:3.19
-RUN apk add --no-cache ca-certificates tzdata
+RUN apk add --no-cache ca-certificates tzdata postgresql-client
+RUN wget -qO- https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xz -C /usr/local/bin migrate
 COPY --from=builder /photoplaces-api /photoplaces-api
 COPY --from=builder /app/migrations /migrations
+COPY docker-entrypoint.sh /docker-entrypoint.sh
+RUN chmod +x /docker-entrypoint.sh
 EXPOSE 8080
-CMD ["/photoplaces-api"]
+ENTRYPOINT ["/docker-entrypoint.sh"]

+ 51 - 29
backend/cmd/api/main.go

@@ -8,7 +8,7 @@ import (
 	"github.com/go-chi/chi/v5"
 	chimiddleware "github.com/go-chi/chi/v5/middleware"
 	"github.com/go-chi/cors"
-	"golang.org/x/time/rate"
+	"github.com/redis/go-redis/v9"
 
 	"github.com/photoplaces/backend/internal/config"
 	"github.com/photoplaces/backend/internal/handlers"
@@ -28,6 +28,7 @@ func main() {
 	defer pool.Close()
 
 	userRepo := repository.NewUserRepo(pool)
+	refreshTokenRepo := repository.NewRefreshTokenRepo(pool)
 	placeRepo := repository.NewPlaceRepo(pool)
 	serviceRepo := repository.NewServiceRepo(pool)
 	reviewRepo := repository.NewReviewRepo(pool)
@@ -35,10 +36,10 @@ func main() {
 	tagRepo := repository.NewTagRepo(pool)
 	featureRepo := repository.NewFeatureRepo(pool)
 
-	authSvc := services.NewAuthService(userRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
+	authSvc := services.NewAuthService(userRepo, refreshTokenRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
 	placeSvc := services.NewPlaceService(placeRepo)
 
-	authHandler := handlers.NewAuthHandler(authSvc)
+	authHandler := handlers.NewAuthHandler(authSvc, cfg.AppEnv)
 	userHandler := handlers.NewUserHandler(userRepo)
 	placeHandler := handlers.NewPlaceHandler(placeSvc)
 	serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo)
@@ -51,7 +52,21 @@ func main() {
 		log.Fatalf("upload handler: %v", err)
 	}
 
-	rl := middleware.NewRateLimiter(rate.Limit(60), 60)
+	redisOpts, err := redis.ParseURL(cfg.RedisURL)
+	if err != nil {
+		log.Fatalf("parse redis url: %v", err)
+	}
+	redisClient := redis.NewClient(redisOpts)
+	defer redisClient.Close()
+
+	if err := redisClient.Ping(ctx).Err(); err != nil {
+		log.Printf("Warning: Redis connection failed, rate limiting disabled: %v", err)
+	}
+
+	authLimiter, _ := middleware.RateLimitAuthEndpoints(redisClient)
+	apiReadLimiter, _ := middleware.RateLimitAPIRead(redisClient)
+	apiWriteLimiter, _ := middleware.RateLimitAPIWrite(redisClient)
+	adminLimiter, _ := middleware.RateLimitAdmin(redisClient)
 
 	r := chi.NewRouter()
 
@@ -61,43 +76,50 @@ func main() {
 	r.Use(cors.Handler(cors.Options{
 		AllowedOrigins:   cfg.AllowedOrigins,
 		AllowedMethods:   []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
-		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-Refresh-Token"},
+		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type"},
 		AllowCredentials: true,
 		MaxAge:           300,
 	}))
-	r.Use(rl.Middleware())
 
 	r.Route("/api/v1", func(r chi.Router) {
 		r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
 			w.Write([]byte(`{"status":"ok"}`))
 		})
 
-		// Public auth
-		r.Post("/auth/register", authHandler.Register)
-		r.Post("/auth/login", authHandler.Login)
-		r.Post("/auth/refresh", authHandler.Refresh)
-
-		// Public reviews (read)
-		r.Get("/reviews", reviewHandler.List)
-
-		// Public tags & features
-		r.Get("/tags", tagHandler.ListTags)
-		r.Get("/features", tagHandler.ListFeatures)
-
-		// Public services (read)
-		r.Get("/services", serviceHandler.List)
-		r.Get("/services/{id}", serviceHandler.GetByID)
+		// Public auth - strict rate limiting
+		r.Group(func(r chi.Router) {
+			if authLimiter != nil {
+				r.Use(authLimiter.Middleware())
+			}
+			r.Post("/auth/register", authHandler.Register)
+			r.Post("/auth/login", authHandler.Login)
+			r.Post("/auth/refresh", authHandler.Refresh)
+		})
 
-		// Public places (read)
-		r.Get("/places", placeHandler.List)
-		r.Get("/places/{id}", placeHandler.GetByID)
+		// Public read endpoints - moderate rate limiting
+		r.Group(func(r chi.Router) {
+			if apiReadLimiter != nil {
+				r.Use(apiReadLimiter.Middleware())
+			}
+			r.Get("/reviews", reviewHandler.List)
+			r.Get("/tags", tagHandler.ListTags)
+			r.Get("/features", tagHandler.ListFeatures)
+			r.Get("/services", serviceHandler.List)
+			r.Get("/services/{id}", serviceHandler.GetByID)
+			r.Get("/places", placeHandler.List)
+			r.Get("/places/{id}", placeHandler.GetByID)
+			r.Get("/users/{id}", userHandler.GetProfile)
+		})
 
 		// WebSocket visitor dots
 		r.Get("/ws/visitors", wsHub.HandleWS)
 
-		// Authenticated
+		// Authenticated write endpoints - stricter rate limiting
 		r.Group(func(r chi.Router) {
 			r.Use(middleware.AuthMiddleware(authSvc))
+			if apiWriteLimiter != nil {
+				r.Use(apiWriteLimiter.Middleware())
+			}
 			r.Get("/auth/me", authHandler.Me)
 			r.Post("/auth/logout", authHandler.Logout)
 
@@ -126,13 +148,13 @@ func main() {
 			r.Delete("/places/{id}", placeHandler.Delete)
 		})
 
-		// Public user profile
-		r.Get("/users/{id}", userHandler.GetProfile)
-
-		// Moderator / Admin
+		// Moderator / Admin - higher limits
 		r.Group(func(r chi.Router) {
 			r.Use(middleware.AuthMiddleware(authSvc))
 			r.Use(middleware.RoleMiddleware("moderator", "superadmin"))
+			if adminLimiter != nil {
+				r.Use(adminLimiter.Middleware())
+			}
 			r.Post("/places/{id}/moderate", placeHandler.Moderate)
 			r.Get("/admin/users", userHandler.AdminListUsers)
 			r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)

+ 31 - 0
backend/docker-entrypoint.sh

@@ -0,0 +1,31 @@
+#!/bin/sh
+set -e
+
+# Extract host from DATABASE_URL for pg_isready
+# DATABASE_URL format: postgres://user:pass@host:port/db?params
+DB_HOST=$(echo "$DATABASE_URL" | sed -n 's/.*@\([^:]*\):.*/\1/p')
+if [ -z "$DB_HOST" ]; then
+    DB_HOST="postgres"
+fi
+
+# Wait for database to be ready
+echo "Waiting for database at $DB_HOST..."
+for i in $(seq 1 30); do
+    if pg_isready -h "$DB_HOST" -U photoplaces -q 2>/dev/null; then
+        echo "Database is ready"
+        break
+    fi
+    if [ $i -eq 30 ]; then
+        echo "Timed out waiting for database"
+        exit 1
+    fi
+    sleep 2
+done
+
+# Run migrations
+echo "Running migrations..."
+migrate -path /migrations -database "$DATABASE_URL" up
+
+# Start application
+echo "Starting application..."
+exec /photoplaces-api

+ 2 - 0
backend/go.mod

@@ -11,6 +11,8 @@ require (
 	github.com/jackc/pgx/v5 v5.5.3
 	github.com/joho/godotenv v1.5.1
 	github.com/minio/minio-go/v7 v7.0.69
+	github.com/redis/go-redis/v9 v9.3.0
+	github.com/ulule/limiter/v3 v3.12.0
 	golang.org/x/crypto v0.18.0
 	golang.org/x/time v0.5.0
 )

+ 42 - 6
backend/internal/config/config.go

@@ -36,17 +36,17 @@ func Load() *Config {
 		AppEnv:    getEnv("APP_ENV", "development"),
 		ServerPort: getEnv("SERVER_PORT", "8080"),
 
-		DatabaseURL: getEnv("DATABASE_URL", "postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?sslmode=disable"),
-		RedisURL:    getEnv("REDIS_URL", "redis://localhost:6379/0"),
+		DatabaseURL: buildDatabaseURL(),
+		RedisURL:    buildRedisURL(),
 
 		S3Endpoint:       getEnv("S3_ENDPOINT", "http://localhost:9000"),
 		S3PublicEndpoint: getEnv("S3_PUBLIC_ENDPOINT", "http://localhost:9000"),
 		S3AccessKey:      getEnv("S3_ACCESS_KEY", "photoplaces"),
-		S3SecretKey: getEnv("S3_SECRET_KEY", "photoplaces_dev"),
-		S3Bucket:    getEnv("S3_BUCKET", "photoplaces"),
+		S3SecretKey:      getSecret("S3_SECRET_KEY", "photoplaces_dev"),
+		S3Bucket:         getEnv("S3_BUCKET", "photoplaces"),
 
-		JWTSecret:        getEnv("JWT_SECRET", "dev-secret"),
-		JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "dev-refresh-secret"),
+		JWTSecret:        getSecret("JWT_SECRET", "dev-secret"),
+		JWTRefreshSecret: getSecret("JWT_REFRESH_SECRET", "dev-refresh-secret"),
 
 		CloudPaymentsPublicID: getEnv("CLOUDPAYMENTS_PUBLIC_ID", ""),
 		CloudPaymentsAPISecret: getEnv("CLOUDPAYMENTS_API_SECRET", ""),
@@ -63,3 +63,39 @@ func getEnv(key, fallback string) string {
 	}
 	return fallback
 }
+
+func getSecret(key, fallback string) string {
+	// Try _FILE variant first (Docker secrets)
+	if v := os.Getenv(key + "_FILE"); v != "" {
+		content, err := os.ReadFile(v)
+		if err == nil {
+			return strings.TrimSpace(string(content))
+		}
+	}
+	// Fallback to env var
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return fallback
+}
+
+func buildDatabaseURL() string {
+	// If DATABASE_URL is explicitly set, use it
+	if v := os.Getenv("DATABASE_URL"); v != "" {
+		return v
+	}
+	// Build from components with secret from file
+	password := getSecret("DB_PASSWORD", "photoplaces_dev")
+	return "postgres://photoplaces:" + password + "@postgres:5432/photoplaces?sslmode=disable"
+}
+
+func buildRedisURL() string {
+	if v := os.Getenv("REDIS_URL"); v != "" {
+		return v
+	}
+	password := getSecret("REDIS_PASSWORD", "")
+	if password != "" {
+		return "redis://:" + password + "@redis:6379/0"
+	}
+	return "redis://redis:6379/0"
+}

+ 63 - 8
backend/internal/handlers/auth.go

@@ -4,17 +4,22 @@ import (
 	"encoding/json"
 	"errors"
 	"net/http"
+	"time"
 
 	"github.com/photoplaces/backend/internal/middleware"
 	"github.com/photoplaces/backend/internal/services"
 )
 
 type AuthHandler struct {
-	authSvc *services.AuthService
+	authSvc  *services.AuthService
+	isProd   bool
 }
 
-func NewAuthHandler(authSvc *services.AuthService) *AuthHandler {
-	return &AuthHandler{authSvc: authSvc}
+func NewAuthHandler(authSvc *services.AuthService, appEnv string) *AuthHandler {
+	return &AuthHandler{
+		authSvc: authSvc,
+		isProd:  appEnv == "production",
+	}
 }
 
 type registerRequest struct {
@@ -41,7 +46,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
 		req.Role = "customer"
 	}
 
-	result, err := h.authSvc.Register(r.Context(), services.RegisterInput{
+	result, refreshToken, err := h.authSvc.Register(r.Context(), services.RegisterInput{
 		Email:    req.Email,
 		Password: req.Password,
 		Role:     req.Role,
@@ -56,6 +61,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	h.setRefreshTokenCookie(w, refreshToken)
 	writeJSON(w, http.StatusCreated, result)
 }
 
@@ -71,7 +77,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	result, err := h.authSvc.Login(r.Context(), services.LoginInput{
+	result, refreshToken, err := h.authSvc.Login(r.Context(), services.LoginInput{
 		Email:    req.Email,
 		Password: req.Password,
 	})
@@ -88,26 +94,39 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	h.setRefreshTokenCookie(w, refreshToken)
 	writeJSON(w, http.StatusOK, result)
 }
 
 func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
-	refreshToken := r.Header.Get("X-Refresh-Token")
+	refreshToken := getRefreshTokenFromCookie(r)
 	if refreshToken == "" {
 		writeError(w, http.StatusBadRequest, "refresh token required")
 		return
 	}
 
-	result, err := h.authSvc.RefreshSession(r.Context(), refreshToken)
+	result, newRefreshToken, err := h.authSvc.RefreshSession(r.Context(), refreshToken)
 	if err != nil {
+		if errors.Is(err, services.ErrTokenReused) {
+			h.clearRefreshTokenCookie(w)
+			writeError(w, http.StatusUnauthorized, "token reused, session revoked")
+			return
+		}
+		h.clearRefreshTokenCookie(w)
 		writeError(w, http.StatusUnauthorized, "invalid or expired refresh token")
 		return
 	}
 
+	h.setRefreshTokenCookie(w, newRefreshToken)
 	writeJSON(w, http.StatusOK, result)
 }
 
 func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+	refreshToken := getRefreshTokenFromCookie(r)
+	if refreshToken != "" {
+		_ = h.authSvc.RevokeSession(r.Context(), refreshToken)
+	}
+	h.clearRefreshTokenCookie(w)
 	w.WriteHeader(http.StatusNoContent)
 }
 
@@ -121,6 +140,42 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, map[string]string{"user_id": userID, "role": middleware.GetUserRole(r.Context())})
 }
 
+const refreshTokenCookieName = "refresh_token"
+
+func (h *AuthHandler) setRefreshTokenCookie(w http.ResponseWriter, token string) {
+	cookie := &http.Cookie{
+		Name:     refreshTokenCookieName,
+		Value:    token,
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   h.isProd,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   int((30 * 24 * time.Hour).Seconds()),
+	}
+	http.SetCookie(w, cookie)
+}
+
+func getRefreshTokenFromCookie(r *http.Request) string {
+	cookie, err := r.Cookie(refreshTokenCookieName)
+	if err != nil {
+		return ""
+	}
+	return cookie.Value
+}
+
+func (h *AuthHandler) clearRefreshTokenCookie(w http.ResponseWriter) {
+	cookie := &http.Cookie{
+		Name:     refreshTokenCookieName,
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   h.isProd,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   -1,
+	}
+	http.SetCookie(w, cookie)
+}
+
 func writeJSON(w http.ResponseWriter, status int, v interface{}) {
 	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(status)
@@ -129,4 +184,4 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
 
 func writeError(w http.ResponseWriter, status int, msg string) {
 	writeJSON(w, status, map[string]string{"error": msg})
-}
+}

+ 122 - 0
backend/internal/middleware/ratelimit_redis.go

@@ -0,0 +1,122 @@
+package middleware
+
+import (
+	"context"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+	"github.com/ulule/limiter/v3"
+	"github.com/ulule/limiter/v3/drivers/store/redisstore"
+	chimiddleware "github.com/go-chi/chi/v5/middleware"
+)
+
+type RedisRateLimiter struct {
+	instance *limiter.Limiter
+	keyFunc  func(*http.Request) string
+}
+
+type RateLimitConfig struct {
+	Rate   limiter.Rate
+	KeyFunc func(*http.Request) string
+}
+
+func NewRedisRateLimiter(redisClient *redis.Client, config RateLimitConfig) (*RedisRateLimiter, error) {
+	store, err := redisstore.NewStoreWithOptions(redisClient, limiter.StoreOptions{
+		Prefix:   "ratelimit",
+		MaxRetry: 3,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	instance := limiter.New(store, config.Rate)
+	return &RedisRateLimiter{
+		instance: instance,
+		keyFunc:  config.KeyFunc,
+	}, nil
+}
+
+func (r *RedisRateLimiter) Middleware() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			key := r.keyFunc(req)
+			ctx, cancel := context.WithTimeout(req.Context(), 100*time.Millisecond)
+			defer cancel()
+
+			limit, err := r.instance.Get(ctx, key)
+			if err != nil {
+				// Fail open - log error but allow request through
+				next.ServeHTTP(w, req)
+				return
+			}
+
+			w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limit.Limit, 10))
+			w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(limit.Remaining, 10))
+			w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(limit.Reset, 10))
+
+			if limit.Reached {
+				writeRateLimitError(w, limit.Reset)
+				return
+			}
+
+			next.ServeHTTP(w, req)
+		})
+	}
+}
+
+func writeRateLimitError(w http.ResponseWriter, reset int64) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Retry-After", strconv.FormatInt(reset, 10))
+	w.WriteHeader(http.StatusTooManyRequests)
+	_, _ = w.Write([]byte(`{"error":"rate limit exceeded"}`))
+}
+
+func GetClientIP(r *http.Request) string {
+	return chimiddleware.GetIP(r)
+}
+
+func KeyByIP(prefix string) func(*http.Request) string {
+	return func(r *http.Request) string {
+		return prefix + ":" + GetClientIP(r)
+	}
+}
+
+func KeyByUserID(prefix string) func(*http.Request) string {
+	return func(r *http.Request) string {
+		userID := GetUserID(r.Context())
+		if userID != "" {
+			return prefix + ":user:" + userID
+		}
+		return prefix + ":ip:" + GetClientIP(r)
+	}
+}
+
+func RateLimitAuthEndpoints(redisClient *redis.Client) (*RedisRateLimiter, error) {
+	return NewRedisRateLimiter(redisClient, RateLimitConfig{
+		Rate: limiter.Rate{Period: time.Minute, Limit: 10},
+		KeyFunc: KeyByIP("auth"),
+	})
+}
+
+func RateLimitAPIRead(redisClient *redis.Client) (*RedisRateLimiter, error) {
+	return NewRedisRateLimiter(redisClient, RateLimitConfig{
+		Rate: limiter.Rate{Period: time.Minute, Limit: 60},
+		KeyFunc: KeyByUserID("api_read"),
+	})
+}
+
+func RateLimitAPIWrite(redisClient *redis.Client) (*RedisRateLimiter, error) {
+	return NewRedisRateLimiter(redisClient, RateLimitConfig{
+		Rate: limiter.Rate{Period: time.Minute, Limit: 10},
+		KeyFunc: KeyByUserID("api_write"),
+	})
+}
+
+func RateLimitAdmin(redisClient *redis.Client) (*RedisRateLimiter, error) {
+	return NewRedisRateLimiter(redisClient, RateLimitConfig{
+		Rate: limiter.Rate{Period: time.Minute, Limit: 100},
+		KeyFunc: KeyByUserID("admin"),
+	})
+}

+ 98 - 0
backend/internal/repository/refresh_tokens.go

@@ -0,0 +1,98 @@
+package repository
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+)
+
+type RefreshTokenRepo struct {
+	pool *pgxpool.Pool
+}
+
+func NewRefreshTokenRepo(pool *pgxpool.Pool) *RefreshTokenRepo {
+	return &RefreshTokenRepo{pool: pool}
+}
+
+type RefreshToken struct {
+	ID        string
+	UserID    string
+	TokenHash string
+	ExpiresAt time.Time
+	CreatedAt time.Time
+	RevokedAt *time.Time
+}
+
+func hashToken(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+func (r *RefreshTokenRepo) Create(ctx context.Context, userID, plainToken string, expiresAt time.Time) error {
+	tokenHash := hashToken(plainToken)
+	_, err := r.pool.Exec(ctx,
+		`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
+		 VALUES ($1, $2, $3)`,
+		userID, tokenHash, expiresAt,
+	)
+	if err != nil {
+		return fmt.Errorf("create refresh token: %w", err)
+	}
+	return nil
+}
+
+func (r *RefreshTokenRepo) GetValid(ctx context.Context, plainToken string) (*RefreshToken, error) {
+	tokenHash := hashToken(plainToken)
+	row := r.pool.QueryRow(ctx,
+		`SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
+		 FROM refresh_tokens
+		 WHERE token_hash = $1 AND expires_at > now() AND revoked_at IS NULL`,
+		tokenHash,
+	)
+
+	var rt RefreshToken
+	err := row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
+	if err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("get refresh token: %w", err)
+	}
+	return &rt, nil
+}
+
+func (r *RefreshTokenRepo) Revoke(ctx context.Context, tokenHash string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
+		tokenHash,
+	)
+	return err
+}
+
+func (r *RefreshTokenRepo) RevokeAllForUser(ctx context.Context, userID string) error {
+	_, err := r.pool.Exec(ctx,
+		`UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
+		userID,
+	)
+	return err
+}
+
+func (r *RefreshTokenRepo) Delete(ctx context.Context, tokenHash string) error {
+	_, err := r.pool.Exec(ctx,
+		`DELETE FROM refresh_tokens WHERE token_hash = $1`,
+		tokenHash,
+	)
+	return err
+}
+
+func (r *RefreshTokenRepo) CleanupExpired(ctx context.Context) error {
+	_, err := r.pool.Exec(ctx,
+		`DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'`,
+	)
+	return err
+}

+ 83 - 54
backend/internal/services/auth.go

@@ -2,6 +2,8 @@ package services
 
 import (
 	"context"
+	"crypto/rand"
+	"encoding/base64"
 	"errors"
 	"fmt"
 	"time"
@@ -17,26 +19,34 @@ var (
 	ErrInvalidCreds   = errors.New("invalid email or password")
 	ErrUserBanned     = errors.New("account is banned")
 	ErrInvalidToken   = errors.New("invalid or expired token")
+	ErrTokenReused    = errors.New("token reused, possible theft")
+)
+
+const (
+	AccessTokenExpiry  = 15 * time.Minute
+	RefreshTokenExpiry = 30 * 24 * time.Hour
+	RefreshTokenBytes  = 32
 )
 
 type AuthService struct {
-	userRepo      *repository.UserRepo
-	jwtSecret     []byte
-	refreshSecret []byte
+	userRepo       *repository.UserRepo
+	refreshTokenRepo *repository.RefreshTokenRepo
+	jwtSecret      []byte
+	refreshSecret  []byte
 }
 
-func NewAuthService(userRepo *repository.UserRepo, jwtSecret, refreshSecret string) *AuthService {
+func NewAuthService(userRepo *repository.UserRepo, refreshTokenRepo *repository.RefreshTokenRepo, jwtSecret, refreshSecret string) *AuthService {
 	return &AuthService{
-		userRepo:      userRepo,
-		jwtSecret:     []byte(jwtSecret),
-		refreshSecret: []byte(refreshSecret),
+		userRepo:        userRepo,
+		refreshTokenRepo: refreshTokenRepo,
+		jwtSecret:       []byte(jwtSecret),
+		refreshSecret:   []byte(refreshSecret),
 	}
 }
 
 type AuthResult struct {
-	User         *models.User `json:"user"`
-	AccessToken  string       `json:"access_token"`
-	RefreshToken string       `json:"refresh_token"`
+	User        *models.User `json:"user"`
+	AccessToken string       `json:"access_token"`
 }
 
 type RegisterInput struct {
@@ -46,15 +56,15 @@ type RegisterInput struct {
 	Name     string
 }
 
-func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthResult, error) {
+func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthResult, string, error) {
 	existing, _ := s.userRepo.GetByEmail(ctx, input.Email)
 	if existing != nil {
-		return nil, ErrEmailExists
+		return nil, "", ErrEmailExists
 	}
 
 	hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
 	if err != nil {
-		return nil, fmt.Errorf("hash password: %w", err)
+		return nil, "", fmt.Errorf("hash password: %w", err)
 	}
 
 	user := &models.User{
@@ -65,7 +75,7 @@ func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthR
 	}
 
 	if err := s.userRepo.Create(ctx, user); err != nil {
-		return nil, fmt.Errorf("create user: %w", err)
+		return nil, "", fmt.Errorf("create user: %w", err)
 	}
 
 	return s.generateTokens(ctx, user)
@@ -76,21 +86,21 @@ type LoginInput struct {
 	Password string
 }
 
-func (s *AuthService) Login(ctx context.Context, input LoginInput) (*AuthResult, error) {
+func (s *AuthService) Login(ctx context.Context, input LoginInput) (*AuthResult, string, error) {
 	user, err := s.userRepo.GetByEmail(ctx, input.Email)
 	if err != nil {
-		return nil, fmt.Errorf("get user: %w", err)
+		return nil, "", fmt.Errorf("get user: %w", err)
 	}
 	if user == nil {
-		return nil, ErrInvalidCreds
+		return nil, "", ErrInvalidCreds
 	}
 
 	if user.Status == "banned" {
-		return nil, ErrUserBanned
+		return nil, "", ErrUserBanned
 	}
 
 	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil {
-		return nil, ErrInvalidCreds
+		return nil, "", ErrInvalidCreds
 	}
 
 	return s.generateTokens(ctx, user)
@@ -102,37 +112,36 @@ type TokenClaims struct {
 	jwt.RegisteredClaims
 }
 
-func (s *AuthService) generateTokens(ctx context.Context, user *models.User) (*AuthResult, error) {
+func (s *AuthService) generateTokens(ctx context.Context, user *models.User) (*AuthResult, string, error) {
 	now := time.Now()
 
 	accessClaims := TokenClaims{
 		UserID: user.ID,
 		Role:   user.Role,
 		RegisteredClaims: jwt.RegisteredClaims{
-			ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
+			ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenExpiry)),
 			IssuedAt:  jwt.NewNumericDate(now),
 		},
 	}
 	accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(s.jwtSecret)
 	if err != nil {
-		return nil, fmt.Errorf("sign access token: %w", err)
+		return nil, "", fmt.Errorf("sign access token: %w", err)
 	}
 
-	refreshClaims := jwt.RegisteredClaims{
-		ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
-		IssuedAt:  jwt.NewNumericDate(now),
-		Subject:   user.ID,
-	}
-	refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(s.refreshSecret)
+	refreshToken, err := generateSecureToken(RefreshTokenBytes)
 	if err != nil {
-		return nil, fmt.Errorf("sign refresh token: %w", err)
+		return nil, "", fmt.Errorf("generate refresh token: %w", err)
+	}
+
+	expiresAt := now.Add(RefreshTokenExpiry)
+	if err := s.refreshTokenRepo.Create(ctx, user.ID, refreshToken, expiresAt); err != nil {
+		return nil, "", fmt.Errorf("store refresh token: %w", err)
 	}
 
 	return &AuthResult{
-		User:         user,
-		AccessToken:  accessToken,
-		RefreshToken: refreshToken,
-	}, nil
+		User:        user,
+		AccessToken: accessToken,
+	}, refreshToken, nil
 }
 
 func (s *AuthService) ValidateAccessToken(tokenString string) (*TokenClaims, error) {
@@ -149,43 +158,63 @@ func (s *AuthService) ValidateAccessToken(tokenString string) (*TokenClaims, err
 	return claims, nil
 }
 
-func (s *AuthService) ValidateRefreshToken(tokenString string) (string, error) {
-	token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
-		return s.refreshSecret, nil
-	})
+func (s *AuthService) RefreshSession(ctx context.Context, plainRefreshToken string) (*AuthResult, string, error) {
+	storedToken, err := s.refreshTokenRepo.GetValid(ctx, plainRefreshToken)
 	if err != nil {
-		return "", ErrInvalidToken
-	}
-	claims, ok := token.Claims.(*jwt.RegisteredClaims)
-	if !ok || !token.Valid {
-		return "", ErrInvalidToken
+		return nil, "", fmt.Errorf("lookup refresh token: %w", err)
 	}
-	return claims.Subject, nil
-}
-
-func (s *AuthService) RefreshSession(ctx context.Context, refreshToken string) (*AuthResult, error) {
-	userID, err := s.ValidateRefreshToken(refreshToken)
-	if err != nil {
-		return nil, err
+	if storedToken == nil {
+		return nil, "", ErrInvalidToken
 	}
 
-	user, err := s.userRepo.GetByID(ctx, userID)
+	user, err := s.userRepo.GetByID(ctx, storedToken.UserID)
 	if err != nil {
-		return nil, fmt.Errorf("get user: %w", err)
+		return nil, "", fmt.Errorf("get user: %w", err)
 	}
 	if user == nil {
-		return nil, ErrInvalidToken
+		return nil, "", ErrInvalidToken
 	}
 	if user.Status == "banned" {
-		return nil, ErrUserBanned
+		return nil, "", ErrUserBanned
+	}
+
+	if err := s.refreshTokenRepo.Delete(ctx, storedToken.TokenHash); err != nil {
+		return nil, "", fmt.Errorf("delete old refresh token: %w", err)
 	}
 
 	return s.generateTokens(ctx, user)
 }
 
+func (s *AuthService) RevokeSession(ctx context.Context, plainRefreshToken string) error {
+	storedToken, err := s.refreshTokenRepo.GetValid(ctx, plainRefreshToken)
+	if err != nil {
+		return fmt.Errorf("lookup refresh token: %w", err)
+	}
+	if storedToken == nil {
+		return nil
+	}
+	return s.refreshTokenRepo.Revoke(ctx, storedToken.TokenHash)
+}
+
+func (s *AuthService) RevokeAllSessions(ctx context.Context, userID string) error {
+	return s.refreshTokenRepo.RevokeAllForUser(ctx, userID)
+}
+
+func (s *AuthService) CleanupExpiredTokens(ctx context.Context) error {
+	return s.refreshTokenRepo.CleanupExpired(ctx)
+}
+
+func generateSecureToken(n int) (string, error) {
+	b := make([]byte, n)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return base64.URLEncoding.EncodeToString(b), nil
+}
+
 func strPtr(s string) *string {
 	if s == "" {
 		return nil
 	}
 	return &s
-}
+}

+ 117 - 0
backend/internal/validator/validator.go

@@ -0,0 +1,117 @@
+package validator
+
+import (
+	"regexp"
+
+	"github.com/go-playground/validator/v10"
+)
+
+var (
+	validate *validator.Validate
+	uuidRegex    = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
+	slugRegex    = regexp.MustCompile(`^[a-z0-9-]+$`)
+	currencyRegex = regexp.MustCompile(`^[A-Z]{3}$`)
+)
+
+func init() {
+	validate = validator.New()
+
+	// Custom validators
+	_ = validate.RegisterValidation("uuid", validateUUID)
+	_ = validate.RegisterValidation("slug", validateSlug)
+	_ = validate.RegisterValidation("latitude", validateLatitude)
+	_ = validate.RegisterValidation("longitude", validateLongitude)
+	_ = validate.RegisterValidation("place_type", validatePlaceType)
+	_ = validate.RegisterValidation("user_role", validateUserRole)
+	_ = validate.RegisterValidation("currency", validateCurrency)
+	_ = validate.RegisterValidation("user_status", validateUserStatus)
+	_ = validate.RegisterValidation("place_status", validatePlaceStatus)
+	_ = validate.RegisterValidation("service_status", validateServiceStatus)
+	_ = validate.RegisterValidation("booking_status", validateBookingStatus)
+}
+
+// Validate validates a struct and returns ValidationErrors if any
+func Validate(s interface{}) error {
+	return validate.Struct(s)
+}
+
+// ValidateVar validates a single variable
+func ValidateVar(field interface{}, tag string) error {
+	return validate.Var(field, tag)
+}
+
+// ValidationErrors wraps validator.ValidationErrors for easier handling
+type ValidationErrors validator.ValidationErrors
+
+func (ve ValidationErrors) Error() string {
+	return validator.ValidationErrors(ve).Error()
+}
+
+// FieldErrors returns a map of field -> error message
+func (ve ValidationErrors) FieldErrors() map[string]string {
+	errs := make(map[string]string)
+	for _, fe := range ve {
+		errs[fe.Field()] = fe.Tag()
+	}
+	return errs
+}
+
+// Custom validators
+
+func validateUUID(fl validator.FieldLevel) bool {
+	return uuidRegex.MatchString(fl.Field().String())
+}
+
+func validateSlug(fl validator.FieldLevel) bool {
+	return slugRegex.MatchString(fl.Field().String())
+}
+
+func validateLatitude(fl validator.FieldLevel) bool {
+	lat := fl.Field().Float()
+	return lat >= -90 && lat <= 90
+}
+
+func validateLongitude(fl validator.FieldLevel) bool {
+	lng := fl.Field().Float()
+	return lng >= -180 && lng <= 180
+}
+
+func validatePlaceType(fl validator.FieldLevel) bool {
+	v := fl.Field().String()
+	return v == "place" || v == "studio"
+}
+
+func validateUserRole(fl validator.FieldLevel) bool {
+	roles := map[string]bool{
+		"customer": true, "landlord": true, "executor": true,
+		"moderator": true, "superadmin": true,
+	}
+	return roles[fl.Field().String()]
+}
+
+func validateCurrency(fl validator.FieldLevel) bool {
+	return currencyRegex.MatchString(fl.Field().String())
+}
+
+func validateUserStatus(fl validator.FieldLevel) bool {
+	statuses := map[string]bool{"active": true, "banned": true, "pending_verification": true}
+	return statuses[fl.Field().String()]
+}
+
+func validatePlaceStatus(fl validator.FieldLevel) bool {
+	statuses := map[string]bool{
+		"draft": true, "pending_moderation": true, "published": true,
+		"rejected": true, "archived": true,
+	}
+	return statuses[fl.Field().String()]
+}
+
+func validateServiceStatus(fl validator.FieldLevel) bool {
+	statuses := map[string]bool{"draft": true, "published": true, "archived": true}
+	return statuses[fl.Field().String()]
+}
+
+func validateBookingStatus(fl validator.FieldLevel) bool {
+	statuses := map[string]bool{"pending": true, "confirmed": true, "cancelled": true, "completed": true}
+	return statuses[fl.Field().String()]
+}

+ 124 - 0
deploy/README.md

@@ -0,0 +1,124 @@
+# Deploy — Secrets Management
+
+## Быстрый старт (local / single server)
+
+```bash
+# 1. Скопируйте шаблон
+cp deploy/env.prod.example deploy/env.prod
+
+# 2. Отредактируйте (или запустите deploy.sh — он предложит сгенерировать секреты)
+vim deploy/env.prod
+
+# 3. Деплой
+bash deploy/deploy.sh
+```
+
+`deploy/env.prod` **не коммитится в git** (в `.gitignore`).
+
+---
+
+## Production: External Secrets Sources
+
+Поддерживаются 4 способа загрузки секретов (в порядке приоритета):
+
+### 1. 1Password CLI (`op`)
+```bash
+export OP_VAULT="Production"
+export OP_ITEM="PhotoPlaces Secrets"
+bash deploy/deploy.sh
+```
+Требует: `op` CLI, авторизованный (`op signin`).
+
+### 2. HashiCorp Vault
+```bash
+export VAULT_ADDR="https://vault.example.com"
+export VAULT_TOKEN="hvs.xxxxx"
+export VAULT_PATH="secret/photoplaces"
+bash deploy/deploy.sh
+```
+Требует: `vault` CLI, `jq`.
+
+### 3. AWS Secrets Manager
+```bash
+export AWS_SECRETS_NAME="prod/photoplaces"
+bash deploy/deploy.sh
+```
+Требует: `aws` CLI, `jq`, IAM права на `secretsmanager:GetSecretValue`.
+
+### 4. Local file (fallback)
+Если ни один из выше не настроен — используется `deploy/env.prod`.
+
+---
+
+## Docker Swarm Mode (Secrets в Swarm)
+
+Если кластер в Swarm mode (`docker info | grep Swarm`), `deploy.sh` автоматически создаёт Docker secrets:
+
+```bash
+# Секреты создаются из переменных окружения (загруженных из любого источника выше)
+docker secret create db_password -
+docker secret create redis_password -
+docker secret create jwt_secret -
+docker secret create jwt_refresh_secret -
+docker secret create s3_secret_key -
+```
+
+В `docker-compose.prod.yml` сервисы читают секреты из `/run/secrets/<name>`.
+
+**Backend config** (`internal/config/config.go`) поддерживает `_FILE` суффикс:
+```go
+// JWT_SECRET_FILE=/run/secrets/jwt_secret → читает из файла
+// JWT_SECRET=fallback → если файл не найден
+```
+
+---
+
+## Required Secrets
+
+| Переменная | Описание | Где используется |
+|------------|----------|------------------|
+| `DB_PASSWORD` | Пароль PostgreSQL | postgres, backend |
+| `REDIS_PASSWORD` | Пароль Redis | redis, backend |
+| `JWT_SECRET` | Подпись access токенов | backend |
+| `JWT_REFRESH_SECRET` | Подпись refresh токенов | backend |
+| `S3_SECRET_KEY` | Пароль MinIO | minio, backend |
+| `S3_PUBLIC_ENDPOINT` | Публичный URL MinIO (для прямых загрузок) | frontend, backend |
+| `NEXT_PUBLIC_API_URL` | Публичный URL API | frontend |
+| `ALLOWED_ORIGINS` | CORS origins (через запятую) | backend |
+
+---
+
+## Генерация секретов вручную
+
+```bash
+# 32 байта = 64 hex chars
+openssl rand -hex 32
+
+# Для паролей БД/Redis можно короче
+openssl rand -base64 24
+```
+
+---
+
+## Безопасность
+
+- ✅ `deploy/env.prod` в `.gitignore`
+- ✅ Docker secrets в Swarm (не в env vars, не в логах)
+- ✅ Поддержка внешних vaults (1Password, Vault, AWS)
+- ✅ Fallback на local файл для разработки
+- ✅ Валидация обязательных переменных перед деплоем
+
+---
+
+## Пример для CI/CD (GitHub Actions)
+
+```yaml
+- name: Deploy to production
+  env:
+    OP_VAULT: ${{ secrets.OP_VAULT }}
+    OP_ITEM: ${{ secrets.OP_ITEM }}
+    OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
+  run: |
+    op signin --account ${{ secrets.OP_ACCOUNT }} --token $OP_SERVICE_ACCOUNT_TOKEN
+    bash deploy/deploy.sh
+```

+ 108 - 49
deploy/deploy.sh

@@ -5,63 +5,122 @@ cd "$(dirname "$0")/.."
 
 ENV_FILE="${1:-deploy/env.prod}"
 
-if [ ! -f "$ENV_FILE" ]; then
-  echo "Ошибка: файл $ENV_FILE не найден"
-  echo "Создай его из шаблона: cp deploy/env.prod.example deploy/env.prod"
-  exit 1
-fi
-
 echo "=== PhotoPlaces — деплой ==="
 
-# 1. Генерация секретов, если не заменены
-check_secret() {
-  local var=$1
-  local val
-  val=$(grep "^${var}=" "$ENV_FILE" | cut -d'=' -f2-)
-  if [[ "$val" == *"CHANGE_ME"* ]] || [[ -z "$val" ]]; then
-    echo "!!! $var не настроен. Сгенерировать случайный? [y/N]"
-    read -r answer
-    if [[ "$answer" == "y" ]]; then
-      local secret
-      secret=$(openssl rand -hex 32)
-      if [[ "$OSTYPE" == "darwin"* ]]; then
-        sed -i '' "s/^${var}=.*/${var}=${secret}/" "$ENV_FILE"
-      else
-        sed -i "s/^${var}=.*/${var}=${secret}/" "$ENV_FILE"
-      fi
-      echo "✓ $var сгенерирован"
+# 1. Load secrets from external source if configured
+# Supported: 1password (op), hashicorp vault, aws secretsmanager, or local file
+load_secrets() {
+    if command -v op >/dev/null 2>&1 && [ -n "${OP_VAULT:-}" ] && [ -n "${OP_ITEM:-}" ]; then
+        echo ">>> Loading secrets from 1Password..."
+        eval $(op read "op://${OP_VAULT}/${OP_ITEM}" --format=env 2>/dev/null || true)
+    elif command -v vault >/dev/null 2>&1 && [ -n "${VAULT_ADDR:-}" ] && [ -n "${VAULT_TOKEN:-}" ] && [ -n "${VAULT_PATH:-}" ]; then
+        echo ">>> Loading secrets from HashiCorp Vault..."
+        vault kv get -format=json "${VAULT_PATH}" | jq -r '.data.data | to_entries[] | "export \(.key)=\(.value)"' > /tmp/vault_env.sh
+        source /tmp/vault_env.sh
+        rm -f /tmp/vault_env.sh
+    elif command -v aws >/dev/null 2>&1 && [ -n "${AWS_SECRETS_NAME:-}" ]; then
+        echo ">>> Loading secrets from AWS Secrets Manager..."
+        aws secretsmanager get-secret-value --secret-id "${AWS_SECRETS_NAME}" --query SecretString --output text | jq -r 'to_entries[] | "export \(.key)=\(.value)"' > /tmp/aws_env.sh
+        source /tmp/aws_env.sh
+        rm -f /tmp/aws_env.sh
+    elif [ -f "$ENV_FILE" ]; then
+        echo ">>> Loading secrets from $ENV_FILE..."
+    else
+        echo "Ошибка: ни один источник секретов не настроен"
+        echo "  - 1Password: export OP_VAULT=... OP_ITEM=..."
+        echo "  - Vault: export VAULT_ADDR=... VAULT_TOKEN=... VAULT_PATH=..."
+        echo "  - AWS: export AWS_SECRETS_NAME=..."
+        echo "  - Local: создайте $ENV_FILE из deploy/env.prod.example"
+        exit 1
     fi
-  fi
 }
 
-check_secret "DB_PASSWORD"
-check_secret "REDIS_PASSWORD"
-check_secret "JWT_SECRET"
-check_secret "JWT_REFRESH_SECRET"
-check_secret "S3_SECRET_KEY"
+load_secrets
 
-# 2. Экспорт переменных
-set -a
-source "$ENV_FILE"
-set +a
+# 2. If using local file, check for CHANGE_ME placeholders and offer to generate
+if [ -f "$ENV_FILE" ]; then
+    set -a
+    source "$ENV_FILE"
+    set +a
 
-# 3. Билд и запуск
-echo ">>> Собираю и запускаю контейнеры..."
-docker compose -f deploy/docker-compose.prod.yml up -d --build
+    check_secret() {
+        local var=$1
+        local val="${!var:-}"
+        if [[ "$val" == *"CHANGE_ME"* ]] || [[ -z "$val" ]]; then
+            echo "!!! $var не настроен. Сгенерировать случайный? [y/N]"
+            read -r answer
+            if [[ "$answer" == "y" ]]; then
+                local secret
+                secret=$(openssl rand -hex 32)
+                if [[ "$OSTYPE" == "darwin"* ]]; then
+                    sed -i '' "s/^${var}=.*/${var}=${secret}/" "$ENV_FILE"
+                else
+                    sed -i "s/^${var}=.*/${var}=${secret}/" "$ENV_FILE"
+                fi
+                echo "✓ $var сгенерирован"
+                export "$var=$secret"
+            else
+                echo "Пропущено. Не забудьте настроить вручную!"
+            fi
+        fi
+    }
 
-# 4. Проверка
-echo ">>> Проверяю здоровье..."
-sleep 5
+    check_secret "DB_PASSWORD"
+    check_secret "REDIS_PASSWORD"
+    check_secret "JWT_SECRET"
+    check_secret "JWT_REFRESH_SECRET"
+    check_secret "S3_SECRET_KEY"
+fi
 
-HEALTH=$(curl -s http://localhost:8080/api/v1/health || echo '{"status":"fail"}')
-echo "API health: $HEALTH"
+# 3. Validate required secrets
+required_vars=("DB_PASSWORD" "REDIS_PASSWORD" "JWT_SECRET" "JWT_REFRESH_SECRET" "S3_SECRET_KEY" "S3_PUBLIC_ENDPOINT" "NEXT_PUBLIC_API_URL" "ALLOWED_ORIGINS")
+missing=()
+for var in "${required_vars[@]}"; do
+    if [ -z "${!var:-}" ]; then
+        missing+=("$var")
+    fi
+done
 
-if echo "$HEALTH" | grep -q '"ok"'; then
-  echo "✓ Деплой успешен!"
-  echo "API:         http://localhost:8080/api/v1"
-  echo "Фронтенд:    http://localhost:3000"
-  echo "MinIO консоль: http://localhost:9001"
-else
-  echo "✗ Что-то пошло не так. Логи:"
-  docker compose -f deploy/docker-compose.prod.yml logs --tail=20
+if [ ${#missing[@]} -gt 0 ]; then
+    echo "Ошибка: отсутствуют обязательные переменные:"
+    printf '  %s\n' "${missing[@]}"
+    exit 1
 fi
+
+# 4. Create Docker secrets if in Swarm mode
+create_docker_secrets() {
+    if docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null | grep -q "active"; then
+        echo ">>> Swarm mode detected, creating Docker secrets..."
+        printf "%s" "$DB_PASSWORD" | docker secret create db_password - 2>/dev/null || docker secret rm db_password && printf "%s" "$DB_PASSWORD" | docker secret create db_password -
+        printf "%s" "$REDIS_PASSWORD" | docker secret create redis_password - 2>/dev/null || docker secret rm redis_password && printf "%s" "$REDIS_PASSWORD" | docker secret create redis_password -
+        printf "%s" "$JWT_SECRET" | docker secret create jwt_secret - 2>/dev/null || docker secret rm jwt_secret && printf "%s" "$JWT_SECRET" | docker secret create jwt_secret -
+        printf "%s" "$JWT_REFRESH_SECRET" | docker secret create jwt_refresh_secret - 2>/dev/null || docker secret rm jwt_refresh_secret && printf "%s" "$JWT_REFRESH_SECRET" | docker secret create jwt_refresh_secret -
+        printf "%s" "$S3_SECRET_KEY" | docker secret create s3_secret_key - 2>/dev/null || docker secret rm s3_secret_key && printf "%s" "$S3_SECRET_KEY" | docker secret create s3_secret_key -
+    fi
+}
+
+create_docker_secrets
+
+# 5. Build and deploy
+echo ">>> Собираю и запускаю контейнеры..."
+docker compose -f deploy/docker-compose.prod.yml up -d --build
+
+# 5. Health check (migrations run in entrypoint)
+echo ">>> Проверяю здоровье (ждём миграции + старт)..."
+for i in $(seq 1 30); do
+    HEALTH=$(curl -sf http://localhost:8080/api/v1/health 2>/dev/null || echo '{"status":"fail"}')
+    if echo "$HEALTH" | grep -q '"ok"'; then
+        break
+    fi
+    if [ $i -eq 30 ]; then
+        echo "✗ Таймаут ожидания здоровья API"
+        docker compose -f deploy/docker-compose.prod.yml logs --tail=30 backend
+        exit 1
+    fi
+    sleep 2
+done
+
+echo "✓ Деплой успешен!"
+echo "API:         ${NEXT_PUBLIC_API_URL}"
+echo "Фронтенд:    http://localhost:3000"
+echo "MinIO консоль: ${S3_PUBLIC_ENDPOINT}:9001"

+ 68 - 23
deploy/docker-compose.prod.yml

@@ -5,6 +5,19 @@ x-common: &common
   networks:
     - photoplaces
 
+# Secrets for Docker Swarm mode (optional, ignored in compose)
+x-secrets: &secrets
+  db_password:
+    external: true
+  redis_password:
+    external: true
+  jwt_secret:
+    external: true
+  jwt_refresh_secret:
+    external: true
+  s3_secret_key:
+    external: true
+
 services:
   postgres:
     <<: *common
@@ -12,24 +25,28 @@ services:
     environment:
       POSTGRES_DB: photoplaces
       POSTGRES_USER: photoplaces
-      POSTGRES_PASSWORD: ${DB_PASSWORD}
+      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
     volumes:
       - pgdata:/var/lib/postgresql/data
     healthcheck:
       test: ["CMD-SHELL", "pg_isready -U photoplaces"]
       interval: 10s
       retries: 5
+    secrets:
+      - db_password
 
   redis:
     <<: *common
     image: redis:7-alpine
-    command: redis-server --requirepass ${REDIS_PASSWORD}
+    command: redis-server --requirepass-file /run/secrets/redis_password
     volumes:
       - redisdata:/data
     healthcheck:
-      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+      test: ["CMD", "redis-cli", "-a", "$(cat /run/secrets/redis_password)", "ping"]
       interval: 10s
       retries: 5
+    secrets:
+      - redis_password
 
   minio:
     <<: *common
@@ -39,22 +56,13 @@ services:
       - miniodata:/data
     environment:
       MINIO_ROOT_USER: ${S3_ACCESS_KEY}
-      MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY}
+      MINIO_ROOT_PASSWORD_FILE: /run/secrets/s3_secret_key
     healthcheck:
       test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
       interval: 10s
       retries: 5
-
-  migrations:
-    <<: *common
-    image: migrate/migrate:v4.17
-    command:
-      -path=/migrations -database "postgres://photoplaces:${DB_PASSWORD}@postgres:5432/photoplaces?sslmode=disable" up
-    volumes:
-      - ./backend/migrations:/migrations
-    depends_on:
-      postgres:
-        condition: service_healthy
+    secrets:
+      - s3_secret_key
 
   backend:
     <<: *common
@@ -63,24 +71,42 @@ services:
       dockerfile: Dockerfile
     ports:
       - "127.0.0.1:8080:8080"
+    env_file:
+      - ./env.prod
     environment:
       APP_ENV: production
       SERVER_PORT: "8080"
-      DATABASE_URL: postgres://photoplaces:${DB_PASSWORD}@postgres:5432/photoplaces?sslmode=disable
-      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
+      DATABASE_URL: postgres://photoplaces:$(cat /run/secrets/db_password)@postgres:5432/photoplaces?sslmode=disable
+      REDIS_URL: redis://:$(cat /run/secrets/redis_password)@redis:6379/0
       S3_ENDPOINT: http://minio:9000
       S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT}
       S3_ACCESS_KEY: ${S3_ACCESS_KEY}
-      S3_SECRET_KEY: ${S3_SECRET_KEY}
+      S3_SECRET_KEY_FILE: /run/secrets/s3_secret_key
       S3_BUCKET: photoplaces
-      JWT_SECRET: ${JWT_SECRET}
-      JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
+      JWT_SECRET_FILE: /run/secrets/jwt_secret
+      JWT_REFRESH_SECRET_FILE: /run/secrets/jwt_refresh_secret
       CLOUDPAYMENTS_PUBLIC_ID: ${CLOUDPAYMENTS_PUBLIC_ID}
       CLOUDPAYMENTS_API_SECRET: ${CLOUDPAYMENTS_API_SECRET}
       ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
     depends_on:
-      migrations:
-        condition: service_completed_successfully
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+      minio:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 10s
+    secrets:
+      - db_password
+      - redis_password
+      - jwt_secret
+      - jwt_refresh_secret
+      - s3_secret_key
 
   frontend:
     <<: *common
@@ -94,7 +120,14 @@ services:
     ports:
       - "127.0.0.1:3000:3000"
     depends_on:
-      - backend
+      backend:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 15s
 
 volumes:
   pgdata:
@@ -103,3 +136,15 @@ volumes:
 
 networks:
   photoplaces:
+
+secrets:
+  db_password:
+    external: true
+  redis_password:
+    external: true
+  jwt_secret:
+    external: true
+  jwt_refresh_secret:
+    external: true
+  s3_secret_key:
+    external: true

+ 27 - 0
deploy/env.prod.example

@@ -0,0 +1,27 @@
+# PostgreSQL
+DB_PASSWORD=
+
+# Redis
+REDIS_PASSWORD=
+
+# JWT (generate: openssl rand -hex 32)
+JWT_SECRET=
+JWT_REFRESH_SECRET=
+
+# S3 / MinIO
+S3_ENDPOINT=http://minio:9000
+S3_PUBLIC_ENDPOINT=
+S3_ACCESS_KEY=photoplaces
+S3_SECRET_KEY=
+
+# CloudPayments (optional, for payments)
+CLOUDPAYMENTS_PUBLIC_ID=
+CLOUDPAYMENTS_API_SECRET=
+
+# Frontend
+NEXT_PUBLIC_API_URL=
+NEXT_PUBLIC_MAP_PROVIDER=yandex
+YANDEX_MAPS_API_KEY=
+
+# CORS (comma-separated domains)
+ALLOWED_ORIGINS=

+ 28 - 3
docker-compose.yml

@@ -18,12 +18,13 @@ services:
 
   redis:
     image: redis:7-alpine
+    command: redis-server --requirepass photoplaces_dev
     ports:
       - "6379:6379"
     volumes:
       - redisdata:/data
     healthcheck:
-      test: ["CMD", "redis-cli", "ping"]
+      test: ["CMD", "redis-cli", "-a", "photoplaces_dev", "ping"]
       interval: 5s
       retries: 5
 
@@ -43,6 +44,17 @@ services:
       interval: 5s
       retries: 5
 
+  migrate:
+    image: migrate/migrate:v4.17
+    command: -path=/migrations -database "postgres://photoplaces:photoplaces_dev@postgres:5432/photoplaces?sslmode=disable" up
+    volumes:
+      - ./backend/migrations:/migrations
+    depends_on:
+      postgres:
+        condition: service_healthy
+    profiles:
+      - tools
+
   backend:
     build:
       context: ./backend
@@ -52,7 +64,7 @@ services:
     environment:
       APP_ENV: development
       DATABASE_URL: postgres://photoplaces:photoplaces_dev@postgres:5432/photoplaces?sslmode=disable
-      REDIS_URL: redis://redis:6379/0
+      REDIS_URL: redis://:photoplaces_dev@redis:6379/0
       S3_ENDPOINT: http://minio:9000
       S3_ACCESS_KEY: photoplaces
       S3_SECRET_KEY: photoplaces_dev
@@ -70,6 +82,12 @@ services:
     volumes:
       - ./backend:/app
       - backend-modules:/app/node_modules
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+      start_period: 10s
 
   frontend:
     build:
@@ -82,10 +100,17 @@ services:
       NEXT_PUBLIC_MAP_PROVIDER: yandex
       NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ""
     depends_on:
-      - backend
+      backend:
+        condition: service_healthy
     volumes:
       - ./frontend:/app
       - frontend-modules:/app/node_modules
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+      start_period: 15s
 
 volumes:
   pgdata:

+ 12 - 22
frontend/src/hooks/useAuth.tsx

@@ -2,7 +2,7 @@
 
 import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
 import type { User } from '@/types'
-import { api, ApiRequestError } from '@/lib/api'
+import { api, ApiRequestError, setAccessToken, getAccessToken } from '@/lib/api'
 
 interface AuthState {
   user: User | null
@@ -19,25 +19,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const [user, setUser] = useState<User | null>(null)
   const [isLoading, setIsLoading] = useState(true)
 
-  const setTokens = useCallback((access: string) => {
-    localStorage.setItem('access_token', access)
-  }, [])
-
-  const clearTokens = useCallback(() => {
-    localStorage.removeItem('access_token')
-  }, [])
-
   const refreshSession = useCallback(async () => {
     try {
-      const res = await api.post<{ access_token: string }>('/auth/refresh')
-      setTokens(res.access_token)
-      const userRes = await api.get<User>('/auth/me')
-      setUser(userRes)
+      const res = await api.get<User>('/auth/me')
+      setUser(res)
     } catch {
-      clearTokens()
+      setAccessToken(null)
       setUser(null)
     }
-  }, [setTokens, clearTokens])
+  }, [])
 
   useEffect(() => {
     refreshSession().finally(() => setIsLoading(false))
@@ -45,23 +35,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
 
   const login = useCallback(async (email: string, password: string) => {
     const res = await api.post<{ user: User; access_token: string }>('/auth/login', { email, password })
-    setTokens(res.access_token)
+    setAccessToken(res.access_token)
     setUser(res.user)
-  }, [setTokens])
+  }, [])
 
   const register = useCallback(async (email: string, password: string, role: string) => {
     const res = await api.post<{ user: User; access_token: string }>('/auth/register', { email, password, role })
-    setTokens(res.access_token)
+    setAccessToken(res.access_token)
     setUser(res.user)
-  }, [setTokens])
+  }, [])
 
   const logout = useCallback(async () => {
     try {
       await api.post('/auth/logout', {})
     } catch {}
-    clearTokens()
+    setAccessToken(null)
     setUser(null)
-  }, [clearTokens])
+  }, [])
 
   return (
     <AuthContext.Provider value={{ user, isLoading, login, register, logout, refreshSession }}>
@@ -74,4 +64,4 @@ export function useAuth() {
   const ctx = useContext(AuthContext)
   if (!ctx) throw new Error('useAuth must be used within AuthProvider')
   return ctx
-}
+}

+ 46 - 4
frontend/src/lib/api.ts

@@ -1,14 +1,45 @@
 const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1'
 
+let accessToken: string | null = null
+let refreshPromise: Promise<string> | null = null
+
+export function setAccessToken(token: string | null) {
+  accessToken = token
+}
+
+export function getAccessToken(): string | null {
+  return accessToken
+}
+
+async function refreshAccessToken(): Promise<string> {
+  if (refreshPromise) return refreshPromise
+
+  refreshPromise = (async () => {
+    const res = await fetch(`${API_URL}/auth/refresh`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'include',
+    })
+    if (!res.ok) throw new Error('Refresh failed')
+    const data = await res.json()
+    return data.access_token
+  })()
+
+  try {
+    return await refreshPromise
+  } finally {
+    refreshPromise = null
+  }
+}
+
 async function request<T>(
   path: string,
   options: RequestInit = {},
+  isRetry = false
 ): Promise<T> {
-  const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null
-
   const headers: HeadersInit = {
     'Content-Type': 'application/json',
-    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+    ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
     ...options.headers,
   }
 
@@ -18,6 +49,17 @@ async function request<T>(
     credentials: 'include',
   })
 
+  if (res.status === 401 && !isRetry && !path.includes('/auth/')) {
+    try {
+      const newToken = await refreshAccessToken()
+      accessToken = newToken
+      return request<T>(path, options, true)
+    } catch {
+      accessToken = null
+      throw new ApiRequestError(401, 'Session expired', null)
+    }
+  }
+
   if (!res.ok) {
     const error = await res.json().catch(() => ({ detail: res.statusText }))
     throw new ApiRequestError(res.status, error.detail || 'Unknown error', error)
@@ -51,4 +93,4 @@ export const api = {
     request<T>(path, { method: 'DELETE' }),
 }
 
-export default api
+export default api

+ 1 - 0
obsidian_data/Photoplaces_data/.obsidian/app.json

@@ -0,0 +1 @@
+{}

+ 1 - 0
obsidian_data/Photoplaces_data/.obsidian/appearance.json

@@ -0,0 +1 @@
+{}

+ 33 - 0
obsidian_data/Photoplaces_data/.obsidian/core-plugins.json

@@ -0,0 +1,33 @@
+{
+  "file-explorer": true,
+  "global-search": true,
+  "switcher": true,
+  "graph": true,
+  "backlink": true,
+  "canvas": true,
+  "outgoing-link": true,
+  "tag-pane": true,
+  "footnotes": false,
+  "properties": true,
+  "page-preview": true,
+  "daily-notes": true,
+  "templates": true,
+  "note-composer": true,
+  "command-palette": true,
+  "slash-command": false,
+  "editor-status": true,
+  "bookmarks": true,
+  "markdown-importer": false,
+  "zk-prefixer": false,
+  "random-note": false,
+  "outline": true,
+  "word-count": true,
+  "slides": false,
+  "audio-recorder": false,
+  "workspaces": false,
+  "file-recovery": true,
+  "publish": false,
+  "sync": true,
+  "bases": true,
+  "webviewer": false
+}

+ 22 - 0
obsidian_data/Photoplaces_data/.obsidian/graph.json

@@ -0,0 +1,22 @@
+{
+  "collapse-filter": true,
+  "search": "",
+  "showTags": false,
+  "showAttachments": false,
+  "hideUnresolved": false,
+  "showOrphans": true,
+  "collapse-color-groups": true,
+  "colorGroups": [],
+  "collapse-display": true,
+  "showArrow": false,
+  "textFadeMultiplier": 0,
+  "nodeSizeMultiplier": 1,
+  "lineSizeMultiplier": 1,
+  "collapse-forces": true,
+  "centerStrength": 0.518713248970312,
+  "repelStrength": 10,
+  "linkStrength": 1,
+  "linkDistance": 250,
+  "scale": 1,
+  "close": true
+}

+ 208 - 0
obsidian_data/Photoplaces_data/.obsidian/workspace.json

@@ -0,0 +1,208 @@
+{
+  "main": {
+    "id": "9921746e36ae97a8",
+    "type": "split",
+    "children": [
+      {
+        "id": "d0afdd5792743e22",
+        "type": "tabs",
+        "dimension": 34.51776649746193,
+        "children": [
+          {
+            "id": "a3137b3e9166eebd",
+            "type": "leaf",
+            "state": {
+              "type": "empty",
+              "state": {},
+              "icon": "lucide-file",
+              "title": "Новая вкладка"
+            }
+          }
+        ]
+      },
+      {
+        "id": "0f487117e0e12eef",
+        "type": "tabs",
+        "dimension": 65.48223350253807,
+        "children": [
+          {
+            "id": "8bd96bec443738ab",
+            "type": "leaf",
+            "state": {
+              "type": "markdown",
+              "state": {
+                "file": "architecture-overview.md",
+                "mode": "source",
+                "source": false
+              },
+              "icon": "lucide-file",
+              "title": "architecture-overview"
+            }
+          }
+        ]
+      }
+    ],
+    "direction": "vertical"
+  },
+  "left": {
+    "id": "50fa7718e14dcd6c",
+    "type": "split",
+    "children": [
+      {
+        "id": "d5aaadd193c3b9c6",
+        "type": "tabs",
+        "children": [
+          {
+            "id": "b092a4ec72b2b8d0",
+            "type": "leaf",
+            "state": {
+              "type": "file-explorer",
+              "state": {
+                "sortOrder": "alphabetical",
+                "autoReveal": false
+              },
+              "icon": "lucide-folder-closed",
+              "title": "Файловый менеджер"
+            }
+          },
+          {
+            "id": "641c88b1ee4d7843",
+            "type": "leaf",
+            "state": {
+              "type": "search",
+              "state": {
+                "query": "",
+                "matchingCase": false,
+                "explainSearch": false,
+                "collapseAll": false,
+                "extraContext": false,
+                "sortOrder": "alphabetical"
+              },
+              "icon": "lucide-search",
+              "title": "Поиск"
+            }
+          },
+          {
+            "id": "5715e4b5f19822c4",
+            "type": "leaf",
+            "state": {
+              "type": "bookmarks",
+              "state": {},
+              "icon": "lucide-bookmark",
+              "title": "Закладки"
+            }
+          }
+        ]
+      }
+    ],
+    "direction": "horizontal",
+    "width": 300
+  },
+  "right": {
+    "id": "d00edf45571d9639",
+    "type": "split",
+    "children": [
+      {
+        "id": "e310a040166e3591",
+        "type": "tabs",
+        "children": [
+          {
+            "id": "96ba7da25741d1cf",
+            "type": "leaf",
+            "state": {
+              "type": "backlink",
+              "state": {
+                "file": "Добро пожаловать.md",
+                "collapseAll": false,
+                "extraContext": false,
+                "sortOrder": "alphabetical",
+                "showSearch": false,
+                "searchQuery": "",
+                "backlinkCollapsed": false,
+                "unlinkedCollapsed": true
+              },
+              "icon": "links-coming-in",
+              "title": "Обратные ссылки для Добро пожаловать"
+            }
+          },
+          {
+            "id": "8885f92d38244284",
+            "type": "leaf",
+            "state": {
+              "type": "outgoing-link",
+              "state": {
+                "file": "Добро пожаловать.md",
+                "linksCollapsed": false,
+                "unlinkedCollapsed": true
+              },
+              "icon": "links-going-out",
+              "title": "Исходящие ссылки из Добро пожаловать"
+            }
+          },
+          {
+            "id": "1720e09c5512389d",
+            "type": "leaf",
+            "state": {
+              "type": "tag",
+              "state": {
+                "sortOrder": "frequency",
+                "useHierarchy": true,
+                "showSearch": false,
+                "searchQuery": ""
+              },
+              "icon": "lucide-tags",
+              "title": "Теги"
+            }
+          },
+          {
+            "id": "a5254ace3a3b117b",
+            "type": "leaf",
+            "state": {
+              "type": "all-properties",
+              "state": {
+                "sortOrder": "frequency",
+                "showSearch": false,
+                "searchQuery": ""
+              },
+              "icon": "lucide-archive",
+              "title": "Все свойства"
+            }
+          },
+          {
+            "id": "f8ca1436543ba1da",
+            "type": "leaf",
+            "state": {
+              "type": "outline",
+              "state": {
+                "file": "Добро пожаловать.md",
+                "followCursor": false,
+                "showSearch": false,
+                "searchQuery": ""
+              },
+              "icon": "lucide-list",
+              "title": "Структура Добро пожаловать"
+            }
+          }
+        ]
+      }
+    ],
+    "direction": "horizontal",
+    "width": 300,
+    "collapsed": true
+  },
+  "left-ribbon": {
+    "hiddenItems": {
+      "switcher:Меню быстрого перехода": false,
+      "graph:Граф": false,
+      "canvas:Создать новый холст": false,
+      "daily-notes:Сегодняшняя заметка": false,
+      "templates:Вставить шаблон": false,
+      "command-palette:Открыть палитру команд": false,
+      "bases:Создать новую базу": false
+    }
+  },
+  "active": "8bd96bec443738ab",
+  "lastOpenFiles": [
+    "Добро пожаловать.md"
+  ]
+}

+ 5 - 0
obsidian_data/Photoplaces_data/.trash/Добро пожаловать.md

@@ -0,0 +1,5 @@
+Это ваше новое *хранилище*.
+
+Запишите что-нибудь, [[создайте ссылку]] или попробуйте [плагин Импорта Markdown-файлов](https://help.obsidian.md/Plugins/Importer)!
+
+Когда вы будете готовы, удалите эту заметку и сделайте хранилище своим собственным.

+ 56 - 0
obsidian_data/Photoplaces_data/architecture-overview.md

@@ -0,0 +1,56 @@
+# Архитектура PhotoPlaces — Обзор (MOC)
+
+**Контекст**: Карта технического долга и архитектурных решений проекта PhotoPlaces для оценки жизнеспособности и планирования рефакторинга.
+
+## Суть
+
+Проект — fullstack приложение (Next.js + Go) для карты мест фотосъёмок, аренды студий и найма фотографов. Использует PostgreSQL + PostGIS, Redis, MinIO. Развертывание через Docker Compose.
+
+## Основные компоненты
+
+### Backend (Go 1.22)
+- **Router**: Chi v5
+- **DB**: pgx/v5 (PostgreSQL + PostGIS)
+- **Auth**: JWT (access 15min + refresh 30d)
+- **Architecture**: Layered (handlers → services → repository)
+- **Migrations**: golang-migrate
+
+### Frontend (Next.js 14 App Router)
+- **State**: React Context (AuthProvider)
+- **API**: Custom fetch wrapper
+- **Maps**: Yandex Maps API
+- **Styling**: Tailwind CSS
+
+### Infrastructure
+- **Local**: `docker-compose.yml` (postgres, redis, minio, backend, frontend)
+- **Prod**: `deploy/docker-compose.prod.yml` + `deploy/deploy.sh`
+
+## Критические проблемы (P0)
+
+| Проблема | Файл | Описание |
+|----------|------|----------|
+| **Refresh tokens не хранятся в БД** | `auth.go` | Refresh токены валидируются только по подписи, нет отзыва, нет ротации, утечка = полный доступ 30 дней |
+| **Rate limiter in-memory** | `ratelimit.go`, `main.go` | Не работает в кластере, сбрасывается при рестарте |
+| **Нет валидации входных данных** | handlers | Ручная проверка `if req.Title == ""`, нет схем валидации |
+| **Hardcoded secrets в dev** | `.env.example`, `docker-compose.yml` | `dev-secret`, `photoplaces_dev` — опасно если попадут в прод |
+| **Нет тестов** | Везде | 0% покрытие — нельзя рефакторить безопасно |
+
+## Архитектурные решения (Decision Records)
+
+- [[decision-jwt-refresh-storage]] — Почему refresh tokens должны храниться в БД
+- [[decision-rate-limiter-redis]] — Переход на Redis-based rate limiting
+- [[decision-validation-library]] — Выбор библиотеки валидации (go-playground/validator)
+- [[decision-test-strategy]] — Стратегия тестирования (unit/integration/e2e)
+
+## Связанные заметки
+
+- [[backend-auth-security]]
+- [[backend-rate-limiting]]
+- [[backend-validation]]
+- [[frontend-api-client]]
+- [[deploy-production-readiness]]
+- [[database-migrations]]
+
+## Теги
+
+#architecture #backend #frontend #security #technical-debt #moc

+ 64 - 0
obsidian_data/Photoplaces_data/backend-auth-security.md

@@ -0,0 +1,64 @@
+# Backend: Безопасность JWT и Refresh Tokens
+
+**Контекст**: В `backend/internal/services/auth.go` реализована JWT-аутентификация с access (15 мин) и refresh (30 дней) токенами. Текущая реализация имеет критические уязвимости.
+
+## Суть
+
+**Refresh токены не сохраняются в БД** — они валидируются только по криптографической подписи. Это значит:
+1. Нет возможности отозвать токен (logout, смена пароля, бан пользователя)
+2. Нет ротации refresh токенов (уязвимость к replay атакам)
+3. Утечка refresh токена = полный доступ на 30 дней
+4. Нельзя ограничить количество активных сессий пользователя
+
+## Код (проблемное место)
+
+```go
+// backend/internal/services/auth.go:152-164
+func (s *AuthService) ValidateRefreshToken(tokenString string) (string, error) {
+    token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
+        return s.refreshSecret, nil  // Только проверка подписи!
+    })
+    // ... нет проверки в БД, нет отзыва
+}
+```
+
+## Решение (Best Practice)
+
+Хранить хеш refresh токена в таблице `refresh_tokens` (уже есть в миграции `000002_create_users.up.sql`):
+
+```sql
+CREATE TABLE refresh_tokens (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id),
+    token_hash VARCHAR(255) NOT NULL,  -- SHA-256 от токена
+    expires_at TIMESTAMPTZ NOT NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+```
+
+### Алгоритм:
+1. При логине: генерируем refresh token → сохраняем `SHA256(token)` в БД → отдаём пользователю plain token
+2. При refresh: хешируем полученный токен → ищем в БД → проверяем `expires_at` → **удаляем старый, создаём новый** (ротация)
+3. При logout / смене пароля / бане: удаляем все токены пользователя из БД
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|--------|-------|--------|
+| **БД + ротация (рекомендую)** | Полный контроль, отзыв, безопасность | Немного сложнее, лишний round-trip к БД |
+| **JWT blacklist в Redis** | Быстро, не трогает БД | Нужно хранить до expiry, нет ротации |
+| **Короткий access + долгий refresh без БД** | Просто | **Небезопасно** — текущее состояние |
+
+## Связанные заметки
+
+- [[decision-jwt-refresh-storage]]
+- [[backend-rate-limiting]]
+- [[architecture-overview]]
+
+## Источник
+
+Задача: Code review PhotoPlaces — выявление критических уязвимостей безопасности
+
+## Теги
+
+#backend #security #jwt #auth #critical #best-practice

+ 79 - 0
obsidian_data/Photoplaces_data/backend-rate-limiting.md

@@ -0,0 +1,79 @@
+# Backend: Rate Limiting — In-Memory Проблема
+
+**Контекст**: В `backend/internal/middleware/ratelimit.go` используется in-memory token bucket (golang.org/x/time/rate). В `main.go:54` создаётся глобальный лимитер на 60 req/min на IP.
+
+## Суть
+
+**In-memory rate limiter не работает в production** при:
+- Масштабировании (multiple replicas) — каждый контейнер имеет свой счётчик
+- Рестарте контейнеров — счётчики сбрасываются
+- Загрузке через Load Balancer — IP клиента может попадать на разные инстансы
+
+## Код (проблемное место)
+
+```go
+// backend/cmd/api/main.go:54
+rl := middleware.NewRateLimiter(rate.Limit(60), 60)  // In-memory!
+r.Use(rl.Middleware())
+```
+
+```go
+// backend/internal/middleware/ratelimit.go (упрощённо)
+type RateLimiter struct {
+    limiter *rate.Limiter  // In-memory token bucket
+}
+```
+
+## Решение: Redis-based Rate Limiting
+
+Использовать Redis с sliding window / token bucket алгоритмом. Ключ: `ratelimit:{ip}:{endpoint}` или `ratelimit:{user_id}:{endpoint}`.
+
+### Библиотеки:
+- `github.com/ulule/limiter/v3` — поддерживает Redis, in-memory, sliding window
+- Или самописный на Lua-скрипте для атомарности
+
+### Пример с ulule/limiter:
+```go
+import (
+    "github.com/redis/go-redis/v9"
+    "github.com/ulule/limiter/v3"
+    "github.com/ulule/limiter/v3/drivers/store/redis"
+)
+
+store := redis.NewStoreWithOptions(redisClient, limiter.StoreOptions{
+    Prefix: "ratelimit",
+})
+rate := limiter.Rate{Period: 1*time.Minute, Limit: 60}
+instance := limiter.New(store, rate)
+```
+
+## Настройка для разных эндпоинтов (по API_CONTRACT.md)
+
+| Эндпоинт | Лимит | Ключ |
+|----------|-------|------|
+| `/auth/*` | 10/min | IP |
+| `GET /places` | 60/min | UserID или IP |
+| `POST /places` | 10/min | UserID |
+| `/admin/*` | 100/min | UserID |
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|--------|-------|--------|
+| **Redis + ulule/limiter (рекомендую)** | Работает в кластере, гибкие правила | Зависимость от Redis |
+| **nginx rate limiting** | На уровне инфраструктуры | Меньше гибкости, сложнее per-user |
+| **Cloudflare / API Gateway** | Не трогает код | Vendor lock-in, платное |
+
+## Связанные заметки
+
+- [[decision-rate-limiter-redis]]
+- [[architecture-overview]]
+- [[deploy-production-readiness]]
+
+## Источник
+
+Задача: Code review PhotoPlaces — rate limiter не работает в проде
+
+## Теги
+
+#backend #rate-limiting #redis #scaling #critical #best-practice

+ 142 - 0
obsidian_data/Photoplaces_data/backend-validation.md

@@ -0,0 +1,142 @@
+# Backend: Валидация входящих данных
+
+**Контекст**: Во всех handlers (`places.go`, `services.go`, `auth.go` и др.) валидация делается вручную через `if req.Field == ""` проверки. Нет единой схемы валидации, нет структурированных ошибок.
+
+## Суть
+
+**Ручная валидация — не масштабируема и ошибочна**:
+- Дублирование кода проверок
+- Неконсистентные сообщения об ошибках
+- Нет валидации типов, форматов (email, URL, UUID, координаты)
+- Сложно поддерживать и тестировать
+
+## Код (проблемное место)
+
+```go
+// backend/internal/handlers/places.go:120-147
+func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
+    var req createPlaceRequest
+    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+        writeError(w, http.StatusBadRequest, "invalid request body")
+        return
+    }
+
+    if req.Title == "" {           // Ручная проверка
+        writeError(w, http.StatusBadRequest, "title is required")
+        return
+    }
+    if req.Type == "" {            // Ручная проверка
+        writeError(w, http.StatusBadRequest, "type is required")
+        return
+    }
+    // ... нет валидации: lat/lng range, URL формат, enum для type, длина строк
+}
+```
+
+## Решение: go-playground/validator v10
+
+Стандарт де-факто в Go экосистеме. Поддерживает:
+- Struct tags: `validate:"required,email,max=255"`
+- Custom validators
+- Перевод ошибок
+- Вложенные структуры
+
+### Пример внедрения:
+
+```go
+// internal/validator/validator.go
+package validator
+
+import (
+    "github.com/go-playground/validator/v10"
+)
+
+var Validate *validator.Validate
+
+func init() {
+    Validate = validator.New()
+    // Регистрация кастомных валидаторов
+    Validate.RegisterValidation("coordinate", validateCoordinate)
+}
+
+func validateCoordinate(fl validator.FieldLevel) bool {
+    lat := fl.Field().Float()
+    return lat >= -90 && lat <= 90
+}
+```
+
+```go
+// internal/handlers/places.go — DTO с тегами
+type createPlaceRequest struct {
+    Title       string   `json:"title" validate:"required,min=1,max=255"`
+    Description *string  `json:"description" validate:"omitempty,max=5000"`
+    Address     *string  `json:"address" validate:"omitempty,max=500"`
+    Lat         float64  `json:"lat" validate:"required,coordinate,min=-90,max=90"`
+    Lng         float64  `json:"lng" validate:"required,coordinate,min=-180,max=180"`
+    Type        string   `json:"type" validate:"required,oneof=place studio"`
+    Tags        []string `json:"tags" validate:"dive,required,alphanum"`
+    Features    []string `json:"features" validate:"dive,required,alphanum"`
+    HourlyRate  *int     `json:"hourly_rate" validate:"omitempty,min=0"`
+    Currency    string   `json:"currency" validate:"omitempty,len=3,uppercase"`
+    MinHours    int      `json:"min_hours" validate:"min=0"`
+}
+```
+
+```go
+// В handler:
+func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
+    var req createPlaceRequest
+    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+        writeError(w, http.StatusBadRequest, "invalid request body")
+        return
+    }
+
+    if err := validator.Validate.Struct(req); err != nil {
+        var ve validator.ValidationErrors
+        if errors.As(err, &ve) {
+            writeValidationError(w, ve)  // Структурированный ответ
+            return
+        }
+        writeError(w, http.StatusBadRequest, err.Error())
+        return
+    }
+    // ...
+}
+```
+
+## Структурированный ответ об ошибках (RFC 7807 / Problem Details)
+
+```json
+{
+  "type": "https://api.photoplaces.ru/errors/validation-error",
+  "title": "Validation Failed",
+  "status": 422,
+  "detail": "One or more fields failed validation",
+  "errors": [
+    { "field": "title", "message": "title is required" },
+    { "field": "lat", "message": "lat must be between -90 and 90" }
+  ]
+}
+```
+
+## Альтернативы
+
+| Библиотека | Плюсы | Минусы |
+|------------|-------|--------|
+| **go-playground/validator (рекомендую)** | Стандарт, быстрый, теги, кастомные правила | Рефлексия (небольшой оверхед) |
+| **go-ozzo/ozzo-validation** | Функциональный стиль, без рефлексии | Менее популярный |
+| **Ручная (текущее)** | Нет зависимостей | Неподдерживаемо, баги |
+
+## Связанные заметки
+
+- [[decision-validation-library]]
+- [[backend-auth-security]]
+- [[architecture-overview]]
+
+## Источник
+
+Задача: Code review PhotoPlaces — отсутствие валидации входных данных
+
+## Теги
+
+#backend #validation #go #best-practice #technical-debt

+ 131 - 0
obsidian_data/Photoplaces_data/database-migrations.md

@@ -0,0 +1,131 @@
+# Database: Миграции и Schema Management
+
+**Контекст**: Миграции в `backend/migrations/` используют `golang-migrate`. Запускаются в проде через sidecar контейнер `migrate/migrate`.
+
+## Суть
+
+**Миграции работают, но есть риски**:
+1. Sidecar паттерн — нет гарантии порядка запуска перед backend
+2. Нет rollback стратегии в проде
+3. Нет проверки миграций в CI
+4. Down-миграции могут быть опасны в проде
+
+## Текущая схема (хорошо)
+
+```
+backend/migrations/
+  000001_create_extensions.up.sql     -- pgcrypto, postgis
+  000002_create_users.up.sql          -- users, refresh_tokens
+  000003_create_tags_features.up.sql  -- справочники
+  000004_create_places.up.sql         -- places, images, tags, features
+  000005_create_services_reviews_bookings.up.sql
+  000006_create_moderation_payments_visitors.up.sql
+```
+
+Каждая миграция имеет `.down.sql` — это правильно.
+
+## Проблема: Порядок запуска в docker-compose.prod.yml
+
+```yaml
+# deploy/docker-compose.prod.yml:48-57
+migrations:
+  image: migrate/migrate:v4.17
+  command:
+    - -path=/migrations
+    - -database "postgres://photoplaces:${DB_PASSWORD}@postgres:5432/photoplaces?sslmode=disable"
+    - up
+  depends_on:
+    postgres:
+      condition: service_healthy
+
+backend:
+  depends_on:
+    migrations:
+      condition: service_completed_successfully  -- Хорошо, но...
+```
+
+**Риск**: Если миграция падает, backend не стартует (хорошо), но нет retry логики. `migrate/migrate` не умеет ретраить подключение к БД.
+
+## Решение: Entrypoint скрипт с retry
+
+```dockerfile
+# backend/Dockerfile — добавить entrypoint
+FROM golang:1.22-alpine AS builder
+# ... build stages ...
+
+FROM alpine:3.19
+RUN apk add --no-cache ca-certificates tzdata postgresql-client
+COPY --from=builder /photoplaces-api /photoplaces-api
+COPY --from=builder /app/migrations /migrations
+COPY docker-entrypoint.sh /docker-entrypoint.sh
+RUN chmod +x /docker-entrypoint.sh
+EXPOSE 8080
+ENTRYPOINT ["/docker-entrypoint.sh"]
+CMD ["/photoplaces-api"]
+```
+
+```bash
+# docker-entrypoint.sh
+#!/bin/sh
+set -e
+
+# Ждём БД с таймаутом
+echo "Waiting for database..."
+for i in $(seq 1 30); do
+    if pg_isready -h postgres -U photoplaces -q; then
+        break
+    fi
+    sleep 2
+done
+
+# Накатываем миграции
+echo "Running migrations..."
+migrate -path /migrations -database "$DATABASE_URL" up
+
+# Стартуем приложение
+exec /photoplaces-api
+```
+
+**Плюсы**: Миграции накатываются в том же контейнере, гарантированно перед стартом, с retry.
+
+## CI/CD: Проверка миграций
+
+```yaml
+# .github/workflows/ci.yml
+- name: Test migrations
+  run: |
+    docker compose -f docker-compose.yml up -d postgres
+    sleep 5
+    docker run --rm --network host \
+      -v $(pwd)/backend/migrations:/migrations \
+      migrate/migrate:v4.17 \
+      -path=/migrations \
+      -database "postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?sslmode=disable" \
+      up
+    # Тест down/up
+    migrate ... down 1
+    migrate ... up
+```
+
+## Best Practices для миграций
+
+| Правило | Почему |
+|---------|--------|
+| **Одна миграция = одно логическое изменение** | Легче откатить, понять историю |
+| **Никогда не редактировать закоммиченные миграции** | Нарушает воспроизводимость |
+| **Идемпотентность где возможно** | `CREATE TABLE IF NOT EXISTS`, `DROP INDEX IF EXISTS` |
+| **Тестировать down миграции** | В проде может понадобиться откат |
+| **Не делать DDL в транзакции** | PostGIS, CREATE INDEX CONCURRENTLY не работают в tx |
+
+## Связанные заметки
+
+- [[deploy-production-readiness]] — entrypoint вместо sidecar
+- [[architecture-overview]]
+
+## Источник
+
+Задача: Code review PhotoPlaces — миграции
+
+## Теги
+
+#database #migrations #golang-migrate #docker #best-practice

+ 88 - 0
obsidian_data/Photoplaces_data/decision-jwt-refresh-storage.md

@@ -0,0 +1,88 @@
+# Decision Record: JWT Refresh Token Storage
+
+**Дата**: 2026-06-11
+**Статус**: Accepted (требует реализации)
+
+## Контекст
+
+Текущая реализация в `backend/internal/services/auth.go` валидирует refresh токены **только по подписи JWT**, не сохраняя их в БД. Таблица `refresh_tokens` создана в миграции `000002_create_users.up.sql` но не используется.
+
+## Проблема
+
+| Риск | Последствие |
+|------|-------------|
+| Утечка refresh токена | Полный доступ к аккаунту на 30 дней |
+| Нет logout / revoke | Пользователь не может завершить сессию на других устройствах |
+| Нет ротации | Replay атаки, кража токена не обнаруживается |
+| Смена пароля не инвалидирует сессии | Скомпрометированный аккаунт остаётся доступным |
+
+## Решение
+
+**Хранить хеш refresh токена в таблице `refresh_tokens` с ротацией при каждом использовании.**
+
+### Схема таблицы (уже существует)
+```sql
+CREATE TABLE refresh_tokens (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    token_hash VARCHAR(255) NOT NULL,      -- SHA-256(token)
+    expires_at TIMESTAMPTZ NOT NULL,        -- now() + 30 days
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    revoked_at TIMESTAMPTZ                  -- для явного отзыва
+);
+
+CREATE UNIQUE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
+CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
+```
+
+### Алгоритм
+
+**Login / Register:**
+1. Генерируем cryptographically secure random token (32 bytes → base64)
+2. Сохраняем в БД: `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, SHA256($2), $3)`
+3. Отдаём пользователю: `access_token` (JWT, 15min) + `refresh_token` (plain, в HttpOnly cookie)
+
+**Refresh:**
+1. Читаем refresh token из HttpOnly cookie
+2. `SELECT * FROM refresh_tokens WHERE token_hash = SHA256($1) AND expires_at > now() AND revoked_at IS NULL`
+3. Если не найден → 401, возможно токен скомпрометирован → **revoke all user tokens** (security event)
+4. **DELETE старый токен** (ротация)
+5. Создаём новый refresh token → сохраняем в БД → ставим новую HttpOnly cookie
+6. Генерируем новый access token → возвращаем в теле ответа
+
+**Logout / Password Change / Ban:**
+1. `UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`
+
+**Cleanup (cron job):**
+1. `DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'`
+
+## Альтернативы (отклонены)
+
+| Вариант | Почему отклонен |
+|---------|-----------------|
+| **JWT Blacklist в Redis** | Нужно хранить до истечения (30 дней), нет ротации, сложнее очистка |
+| **Короткий refresh (1 день) без БД** | Плохой UX — частый релогин, не решает проблему утечки |
+| **Access token в куке, refresh в localStorage** | localStorage уязвим к XSS, HttpOnly cookie безопаснее |
+
+## Последствия
+
+**Положительные:**
+- Полный контроль сессий
+- Безопасный logout, смена пароля, бан
+- Детекция кражи токена (reuse detection)
+- Соответствие OWASP Auth Cheat Sheet
+
+**Отрицательные:**
+- +1 запрос к БД при каждом refresh (микросекунды, кэшируется)
+- Нужно мигрировать существующие токены (одноразовый скрипт)
+- Сложнее горизонтальное масширование (требует shared БД — уже есть)
+
+## Связанные заметки
+
+- [[backend-auth-security]]
+- [[frontend-api-client]] — HttpOnly cookie для refresh
+- [[architecture-overview]]
+
+## Теги
+
+#decision-record #security #jwt #auth #architecture

+ 153 - 0
obsidian_data/Photoplaces_data/decision-rate-limiter-redis.md

@@ -0,0 +1,153 @@
+# Decision Record: Redis-based Rate Limiting
+
+**Дата**: 2026-06-11
+**Статус**: Accepted (требует реализации)
+
+## Контекст
+
+Текущий rate limiter в `backend/internal/middleware/ratelimit.go` использует in-memory `golang.org/x/time/rate`. В `main.go:54` создаётся глобальный лимитер на 60 req/min на IP.
+
+## Проблема
+
+In-memory rate limiter **не работает в production** при:
+- Горизонтальном масштабировании (multiple replicas)
+- Рестартах контейнеров (сброс счётчиков)
+- Балансировке нагрузки (разные IP на разных инстансах)
+
+## Решение
+
+**Использовать Redis-backed rate limiter с библиотекой `github.com/ulule/limiter/v3`.**
+
+### Почему ulule/limiter:
+- Поддерживает Redis, in-memory, Memcached
+- Sliding window / token bucket алгоритмы
+- Атомарные операции через Lua-скрипты
+- Гибкие ключи (IP, UserID, custom)
+- Готовые middleware для Chi, Gin, Echo, stdlib
+
+### Реализация
+
+```go
+// internal/middleware/ratelimit_redis.go
+package middleware
+
+import (
+    "context"
+    "net/http"
+    "time"
+
+    "github.com/redis/go-redis/v9"
+    "github.com/ulule/limiter/v3"
+    "github.com/ulule/limiter/v3/drivers/store/redis"
+    "github.com/go-chi/chi/v5/middleware"
+)
+
+type RedisRateLimiter struct {
+    instance *limiter.Limiter
+    keyFunc  func(*http.Request) string
+}
+
+func NewRedisRateLimiter(redisClient *redis.Client, rate limiter.Rate, keyFunc func(*http.Request) string) *RedisRateLimiter {
+    store := redis.NewStoreWithOptions(redisClient, limiter.StoreOptions{Prefix: "ratelimit"})
+    instance := limiter.New(store, rate)
+    return &RedisRateLimiter{instance: instance, keyFunc: keyFunc}
+}
+
+func (r *RedisRateLimiter) Middleware() func(http.Handler) http.Handler {
+    return func(next http.Handler) http.Handler {
+        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+            key := r.keyFunc(req)
+            ctx := context.Background()
+            
+            limitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
+            defer cancel()
+            
+            limit, err := r.instance.Get(limitCtx, key)
+            if err != nil {
+                // Fail open или fail closed — решение бизнеса
+                // Рекомендую fail open с логированием
+                next.ServeHTTP(w, req)
+                return
+            }
+            
+            w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limit.Limit, 10))
+            w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(limit.Remaining, 10))
+            w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(limit.Reset, 10))
+            
+            if limit.Reached {
+                writeRateLimitError(w, limit.Reset)
+                return
+            }
+            
+            next.ServeHTTP(w, req)
+        })
+    }
+}
+
+// В main.go — настройка разных лимитов для разных групп
+func setupRateLimiters(r *chi.Mux, redisClient *redis.Client) {
+    // Auth endpoints: 10/min по IP
+    authLimiter := NewRedisRateLimiter(redisClient, limiter.Rate{Period: time.Minute, Limit: 10}, func(r *http.Request) string {
+        return "auth:" + middleware.GetIP(r)
+    })
+    r.Group(func(r chi.Router) {
+        r.Use(authLimiter.Middleware())
+        r.Post("/auth/register", ...)
+        r.Post("/auth/login", ...)
+    })
+    
+    // API read: 60/min по UserID (если авторизован) или IP
+    apiReadLimiter := NewRedisRateLimiter(redisClient, limiter.Rate{Period: time.Minute, Limit: 60}, func(r *http.Request) string {
+        if userID := middleware.GetUserID(r.Context()); userID != "" {
+            return "api_read:user:" + userID
+        }
+        return "api_read:ip:" + middleware.GetIP(r)
+    })
+    r.Group(func(r chi.Router) {
+        r.Use(apiReadLimiter.Middleware())
+        r.Get("/places", ...)
+        r.Get("/services", ...)
+    })
+    
+    // API write: 10/min по UserID
+    apiWriteLimiter := NewRedisRateLimiter(redisClient, limiter.Rate{Period: time.Minute, Limit: 10}, func(r *http.Request) string {
+        return "api_write:user:" + middleware.GetUserID(r.Context())
+    })
+    r.Group(func(r chi.Router) {
+        r.Use(apiWriteLimiter.Middleware())
+        r.Post("/places", ...)
+        r.Post("/services", ...)
+    })
+}
+```
+
+## Альтернативы (отклонены)
+
+| Вариант | Почему отклонен |
+|---------|-----------------|
+| **nginx rate limiting** | Нет per-user лимитов, меньше гибкости, сложнее тестировать |
+| **Cloudflare / API Gateway** | Vendor lock-in, стоимость, задержка конфигурации |
+| **In-memory с sticky sessions** | Антипаттерн, ломает масштабируемость |
+
+## Последствия
+
+**Положительные:**
+- Работает в кластере
+- Гибкие правила per endpoint / per user / per IP
+- Стандартные заголовки `X-RateLimit-*`
+- Атомарность через Redis Lua
+
+**Отрицательные:**
+- Зависимость от доступности Redis (fail open стратегия)
+- Небольшая латентность (~1-2ms)
+- Нужно мониторить Redis память
+
+## Связанные заметки
+
+- [[backend-rate-limiting]]
+- [[deploy-production-readiness]] — Redis HA для прод
+- [[architecture-overview]]
+
+## Теги
+
+#decision-record #rate-limiting #redis #scaling #architecture

+ 194 - 0
obsidian_data/Photoplaces_data/decision-test-strategy.md

@@ -0,0 +1,194 @@
+# Decision Record: Test Strategy
+
+**Дата**: 2026-06-11
+**Статус**: Accepted (требует реализации)
+
+## Контекст
+
+В проекте **0% тестов**. Ни unit, ни integration, ни e2e. Это блокер для безопасного рефакторинга и CI/CD.
+
+## Решение
+
+**Трёхслойная стратегия тестирования** с целевым покрытием:
+
+| Слой | Цель покрытия | Инструменты | Что тестируем |
+|------|---------------|-------------|---------------|
+| **Unit** | 70%+ | Go: `testing`, `testify`, `gomock` / `go.uber.org/mock`<br>TS: `vitest`, `react-testing-library` | Бизнес-логика (services), утилиты, валидаторы, мапперы |
+| **Integration** | Критичные пути | Go: `testcontainers-go` (PostgreSQL, Redis, MinIO)<br>TS: `msw` (Mock Service Worker) | Repository слой, HTTP handlers, API контракты, auth flow |
+| **E2E** | Happy paths | `Playwright` (TypeScript) | Critical user journeys: регистрация → создание места → бронирование → оплата |
+
+## Backend: Unit тесты (services)
+
+```go
+// internal/services/auth_service_test.go
+func TestAuthService_Register(t *testing.T) {
+    ctrl := gomock.NewController(t)
+    defer ctrl.Finish()
+    
+    mockRepo := repository.NewMockUserRepo(ctrl)
+    svc := services.NewAuthService(mockRepo, "secret", "refresh-secret")
+    
+    mockRepo.EXPECT().GetByEmail(gomock.Any(), "test@example.com").Return(nil, nil)
+    mockRepo.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
+        func(ctx context.Context, u *models.User) error {
+            u.ID = "uuid-123"
+            return nil
+        }).Times(1)
+    
+    result, err := svc.Register(context.Background(), services.RegisterInput{
+        Email: "test@example.com", Password: "pass123", Role: "customer", Name: "Test",
+    })
+    
+    assert.NoError(t, err)
+    assert.NotEmpty(t, result.AccessToken)
+    assert.NotEmpty(t, result.RefreshToken)
+    assert.Equal(t, "customer", result.User.Role)
+}
+```
+
+## Backend: Integration тесты (handlers + DB)
+
+```go
+// internal/handlers/places_integration_test.go
+func TestPlaceHandler_Create(t *testing.T) {
+    // testcontainers: запустить postgres
+    ctx := context.Background()
+    pgContainer, _ := postgres.RunContainer(ctx, testcontainers.WithImage("postgis/postgis:16-3.4-alpine"))
+    defer pgContainer.Terminate(ctx)
+    
+    // Накатить миграции
+    dbURL := pgContainer.ConnectionString(ctx)
+    migrate.Up(dbURL)
+    
+    // Создать реальный pool, repo, service, handler
+    pool := repository.NewPool(ctx, dbURL)
+    placeRepo := repository.NewPlaceRepo(pool)
+    placeSvc := services.NewPlaceService(placeRepo)
+    handler := handlers.NewPlaceHandler(placeSvc)
+    
+    // Сделать HTTP запрос через httptest
+    req := httptest.NewRequest("POST", "/api/v1/places", strings.NewReader(`{"title":"Test","type":"place","lat":55.75,"lng":37.61}`))
+    req.Header.Set("Authorization", "Bearer "+generateTestToken("user-1", "customer"))
+    w := httptest.NewRecorder()
+    handler.Create(w, req)
+    
+    assert.Equal(t, http.StatusCreated, w.Code)
+}
+```
+
+## Frontend: Unit тесты (components, hooks, utils)
+
+```typescript
+// src/hooks/__tests__/useAuth.test.tsx
+import { renderHook, act } from '@testing-library/react'
+import { AuthProvider, useAuth } from '@/hooks/useAuth'
+import { api } from '@/lib/api'
+
+vi.mock('@/lib/api')
+
+test('login sets user and token', async () => {
+  const mockUser = { id: '1', email: 'test@test.com', role: 'customer' }
+  vi.mocked(api.post).mockResolvedValue({ user: mockUser, access_token: 'token123' })
+  
+  const wrapper = ({ children }: { children: React.ReactNode }) => (
+    <AuthProvider>{children}</AuthProvider>
+  )
+  
+  const { result } = renderHook(() => useAuth(), { wrapper })
+  
+  await act(async () => {
+    await result.current.login('test@test.com', 'password')
+  })
+  
+  expect(result.current.user).toEqual(mockUser)
+  expect(localStorage.getItem('access_token')).toBe('token123')
+})
+```
+
+## E2E: Playwright critical paths
+
+```typescript
+// e2e/booking.spec.ts
+test('user can book a studio', async ({ page }) => {
+  await page.goto('/login')
+  await page.fill('[name=email]', 'user@test.com')
+  await page.fill('[name=password]', 'password')
+  await page.click('button[type=submit]')
+  
+  await page.goto('/studios/studio-123')
+  await page.click('[data-testid=book-button]')
+  await page.fill('[name=start_time]', '2024-02-01T10:00')
+  await page.fill('[name=end_time]', '2024-02-01T14:00')
+  await page.click('button[type=submit]')
+  
+  await expect(page.locator('[data-testid=booking-confirmation]')).toBeVisible()
+})
+```
+
+## CI Pipeline
+
+```yaml
+# .github/workflows/ci.yml
+jobs:
+  backend-test:
+    runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgis/postgis:16-3.4-alpine
+        env: { POSTGRES_DB: test, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
+        ports: [5432:5432]
+        options: >-
+          --health-cmd "pg_isready -U test"
+          --health-interval 5s
+          --health-timeout 5s
+          --health-retries 5
+      redis:
+        image: redis:7-alpine
+        ports: [6379:6379]
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v5
+        with: { go-version: '1.22' }
+      - run: go test ./backend/... -race -coverprofile=coverage.out
+      - uses: codecov/codecov-action@v4
+  
+  frontend-test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with: { node-version: '20', cache: 'npm', cache-dependency-path: frontend/package-lock.json }
+      - run: cd frontend && npm ci && npm run test:ci
+  
+  e2e-test:
+    runs-on: ubuntu-latest
+    needs: [backend-test, frontend-test]
+    steps:
+      - uses: actions/checkout@v4
+      - run: docker compose -f deploy/docker-compose.prod.yml up -d
+      - run: npx playwright install --with-deps chromium
+      - run: npx playwright test
+```
+
+## Последствия
+
+**Положительные:**
+- Безопасный рефакторинг
+- Документация поведения через тесты
+- CI как quality gate
+- Регрессии ловятся автоматически
+
+**Отрицательные:**
+- Время на написание (инвестиция)
+- Поддержка тестов при изменениях
+- testcontainers медленные в CI (нужен кэш)
+
+## Связанные заметки
+
+- [[backend-auth-security]] — тесты для refresh token rotation
+- [[backend-validation]] — тесты для кастомных валидаторов
+- [[architecture-overview]]
+
+## Теги
+
+#decision-record #testing #ci-cd #unit #integration #e2e #best-practice

+ 110 - 0
obsidian_data/Photoplaces_data/decision-validation-library.md

@@ -0,0 +1,110 @@
+# Decision Record: Validation Library — go-playground/validator
+
+**Дата**: 2026-06-11
+**Статус**: Accepted (требует реализации)
+
+## Контекст
+
+Валидация во всех handlers делается вручную через `if req.Field == ""` проверки. Нет единой схемы, неконсистентные ошибки, нет валидации форматов.
+
+## Решение
+
+**Использовать `github.com/go-playground/validator/v10`** — стандарт де-факто в Go.
+
+### Почему не альтернативы:
+
+| Библиотека | Причина отказа |
+|------------|----------------|
+| go-ozzo/ozzo-validation | Функциональный стиль, менее популярный, меньше примеров |
+| Ручная валидация | Неподдерживаемо, баги, дублирование |
+| CUE / JsonSchema | Overengineering для REST API |
+
+### План внедрения
+
+1. **Добавить зависимость**: `go get github.com/go-playground/validator/v10`
+2. **Создать пакет** `internal/validator/` с инициализацией и кастомными правилами
+3. **Пометить DTO тегами** во всех handlers
+4. **Добавить middleware/хелпер** для единообразных ответов об ошибках (RFC 7807)
+5. **Покрыть тестами** кастомные валидаторы
+
+### Кастомные валидаторы (понадобятся)
+
+```go
+// internal/validator/validator.go
+func init() {
+    Validate.RegisterValidation("coordinate", validateCoordinate)
+    Validate.RegisterValidation("place_type", validatePlaceType)
+    Validate.RegisterValidation("user_role", validateUserRole)
+    Validate.RegisterValidation("currency", validateCurrency) // ISO 4217
+}
+
+func validateCoordinate(fl validator.FieldLevel) bool {
+    v := fl.Field().Float()
+    return v >= -90 && v <= 90 // для lat; для lng нужен отдельный или min/max
+}
+
+func validatePlaceType(fl validator.FieldLevel) bool {
+    v := fl.Field().String()
+    return v == "place" || v == "studio"
+}
+
+func validateUserRole(fl validator.FieldLevel) bool {
+    roles := map[string]bool{"customer": true, "landlord": true, "executor": true, "moderator": true, "superadmin": true}
+    return roles[fl.Field().String()]
+}
+```
+
+### Пример DTO с тегами
+
+```go
+type CreatePlaceRequest struct {
+    Title       string   `json:"title" validate:"required,min=1,max=255"`
+    Description *string  `json:"description" validate:"omitempty,max=5000"`
+    Address     *string  `json:"address" validate:"omitempty,max=500"`
+    Lat         float64  `json:"lat" validate:"required,coordinate,min=-90,max=90"`
+    Lng         float64  `json:"lng" validate:"required,coordinate,min=-180,max=180"`
+    Type        string   `json:"type" validate:"required,place_type"`
+    Tags        []string `json:"tags" validate:"dive,required,alphanum,max=50"`
+    Features    []string `json:"features" validate:"dive,required,alphanum,max=50"`
+    HourlyRate  *int     `json:"hourly_rate" validate:"omitempty,min=0"`
+    Currency    string   `json:"currency" validate:"omitempty,len=3,uppercase,currency"`
+    MinHours    int      `json:"min_hours" validate:"min=0"`
+}
+```
+
+### RFC 7807 Problem Details ответ
+
+```json
+{
+  "type": "https://api.photoplaces.ru/errors/validation-error",
+  "title": "Validation Failed",
+  "status": 422,
+  "detail": "One or more fields failed validation",
+  "instance": "/api/v1/places",
+  "errors": [
+    { "field": "title", "message": "title is required" },
+    { "field": "lat", "message": "lat must be between -90 and 90" }
+  ]
+}
+```
+
+## Последствия
+
+**Положительные:**
+- Декларативная валидация в DTO
+- Консистентные ошибки
+- Легко тестировать
+- Стандарт индустрии
+
+**Отрицательные:**
+- Рефлексия (небольшой оверхед ~1-2µs)
+- Нужно обновить все handlers
+
+## Связанные заметки
+
+- [[backend-validation]]
+- [[architecture-overview]]
+
+## Теги
+
+#decision-record #validation #go #best-practice

+ 163 - 0
obsidian_data/Photoplaces_data/deploy-production-readiness.md

@@ -0,0 +1,163 @@
+# Deploy: Production Readiness Checklist
+
+**Контекст**: В `deploy/` есть `docker-compose.prod.yml`, `deploy.sh`, `env.prod`. Локально — `docker-compose.yml` + `Makefile`.
+
+## Суть
+
+**Проект разворачивается, но не production-ready** — есть риски безопасности, надежности и операционности.
+
+## Критические проблемы (P0)
+
+| Проблема | Файл | Риск |
+|----------|------|------|
+| **Secrets в plain text** | `env.prod`, `docker-compose.yml` | Утечка БД, JWT, S3 при коммите / доступе к серверу |
+| **Нет healthcheck у backend/frontend в prod** | `docker-compose.prod.yml` | Контейнеры могут быть "up" но не готовы к трафику |
+| **Миграции не гарантированно накатываются** | `docker-compose.prod.yml:52` | `migrate/migrate` запускается как sidecar, нет retry логики |
+| **Нет reverse proxy (nginx/traefik)** | `docker-compose.prod.yml` | Прямой доступ к портам 8080/3000, нет SSL, нет rate limiting на входе |
+| **Single instance БД/Redis/MinIO** | `docker-compose.prod.yml` | SPOF, нет HA, нет бэкапов |
+| **Hardcoded IP в env.prod** | `env.prod:13,22,27` | Не переносимо, не работает за доменом |
+
+## Рекомендуемые изменения
+
+### 1. Secrets Management
+```bash
+# Не хранить секреты в репозитории!
+# Использовать:
+# - Docker secrets (swarm mode)
+# - HashiCorp Vault / AWS Secrets Manager / 1Password CLI
+# - .env файл только на сервере (chmod 600), в .gitignore
+```
+
+### 2. Reverse Proxy (Nginx/Traefik/Caddy)
+```yaml
+# docker-compose.prod.yml — добавить сервис
+  proxy:
+    image: caddy:2-alpine
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./Caddyfile:/etc/caddy/Caddyfile
+      - caddy_data:/data
+      - caddy_config:/config
+    depends_on:
+      - backend
+      - frontend
+```
+```caddyfile
+# Caddyfile — автоматический HTTPS через Let's Encrypt
+photoplaces.ru {
+    reverse_proxy frontend:3000
+}
+
+api.photoplaces.ru {
+    reverse_proxy backend:8080
+}
+```
+
+### 3. Healthchecks для всех сервисов
+```yaml
+# backend healthcheck
+backend:
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 10s
+
+# frontend healthcheck
+frontend:
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+```
+
+### 4. Database Backups
+```yaml
+# Добавить сервис бэкапа
+  postgres-backup:
+    image: prodrigestivill/postgres-backup-local
+    environment:
+      POSTGRES_HOST: postgres
+      POSTGRES_DB: photoplaces
+      POSTGRES_USER: photoplaces
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+      SCHEDULE: "@daily"
+      BACKUP_KEEP_DAYS: 30
+    volumes:
+      - ./backups:/backups
+```
+
+### 5. Логирование и мониторинг
+```yaml
+# Добавить Loki + Promtail или ELK
+  loki:
+    image: grafana/loki:2.9
+    ports: ["3100:3100"]
+  
+  promtail:
+    image: grafana/promtail:2.9
+    volumes:
+      - /var/log:/var/log
+      - ./promtail.yml:/etc/promtail.yml
+```
+
+### 6. Упрощённый деплой (одна команда)
+```bash
+# deploy/deploy.sh — улучшенная версия
+#!/usr/bin/env bash
+set -euo pipefail
+
+ENV_FILE="${1:-deploy/env.prod}"
+[ -f "$ENV_FILE" ] || { echo "Create $ENV_FILE from example"; exit 1; }
+
+# 1. Load secrets (support 1Password, Vault, etc.)
+source "$ENV_FILE"
+
+# 2. Build images with tags
+VERSION=$(git describe --tags --always --dirty)
+docker compose -f deploy/docker-compose.prod.yml build --build-arg VERSION="$VERSION"
+
+# 3. Run migrations (with retry)
+docker compose -f deploy/docker-compose.prod.yml run --rm migrations
+
+# 4. Deploy with zero-downtime (rolling update)
+docker compose -f deploy/docker-compose.prod.yml up -d --remove-orphans
+
+# 5. Health checks
+sleep 10
+curl -sf http://localhost/health || { echo "Health check failed"; exit 1; }
+
+echo "✓ Deployed version $VERSION"
+```
+
+## Чек-лист перед продакшеном
+
+- [ ] Все секреты вынесены из репозитория
+- [ ] Настроен HTTPS (Let's Encrypt / Caddy / Traefik)
+- [ ] Healthchecks на всех сервисах
+- [ ] Автоматические бэкапы БД (проверено restore)
+- [ ] Логи агрегируются (Loki/ELK)
+- [ ] Метрики собираются (Prometheus + Grafana)
+- [ ] Настроены алерты (down, high error rate, high latency)
+- [ ] Rate limiting на уровне прокси + приложение
+- [ ] CORS настроен только для продакшен доменов
+- [ ] Security headers (CSP, HSTS, X-Frame-Options)
+- [ ] Нагрузочное тестирование пройдено
+
+## Связанные заметки
+
+- [[backend-rate-limiting]] — Rate limiter нужен Redis для прод
+- [[architecture-overview]]
+- [[decision-reverse-proxy]]
+
+## Источник
+
+Задача: Code review PhotoPlaces — production readiness
+
+## Теги
+
+#deploy #docker #production #security #monitoring #best-practice

+ 146 - 0
obsidian_data/Photoplaces_data/frontend-api-client.md

@@ -0,0 +1,146 @@
+# Frontend: API Client и Error Handling
+
+**Контекст**: В `frontend/src/lib/api.ts` реализован простой wrapper над `fetch`. В `useAuth.tsx` используется для авторизации.
+
+## Суть
+
+**Текущий API клиент имеет пробелы**:
+1. Нет автоматического refresh токена при 401
+2. Нет retry логики для сетевых ошибок
+3. Нет типизированных ошибок по эндпоинтам
+4. `credentials: 'include'` + `Authorization` header — потенциальный CSRF (хотя SameSite=Lax помогает)
+5. Нет request/response interceptors для логирования, метрик
+
+## Код (проблемные места)
+
+```typescript
+// frontend/src/lib/api.ts:15-26
+const res = await fetch(`${API_URL}${path}`, {
+    ...options,
+    headers,
+    credentials: 'include',  // Куки + Bearer токен одновременно
+})
+
+if (!res.ok) {
+    const error = await res.json().catch(() => ({ detail: res.statusText }))
+    throw new ApiRequestError(res.status, error.detail || 'Unknown error', error)
+}
+```
+
+```tsx
+// frontend/src/hooks/useAuth.tsx:30-40
+const refreshSession = useCallback(async () => {
+    try {
+        const res = await api.post<{ access_token: string }>('/auth/refresh')
+        setTokens(res.access_token)
+        const userRes = await api.get<User>('/auth/me')
+        setUser(userRes)
+    } catch {
+        clearTokens()
+        setUser(null)
+    }
+}, [setTokens, clearTokens])
+// Проблема: refresh вызывается при каждом монтировании, нет защиты от race condition
+```
+
+## Решение: Улучшенный API клиент
+
+### 1. Автоматический refresh при 401 (с защитой от race condition)
+
+```typescript
+// frontend/src/lib/api.ts
+let refreshPromise: Promise<string> | null = null
+
+async function refreshAccessToken(): Promise<string> {
+    if (refreshPromise) return refreshPromise
+    
+    refreshPromise = (async () => {
+        const res = await fetch(`${API_URL}/auth/refresh`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            credentials: 'include',  // Refresh token в HttpOnly куке
+        })
+        if (!res.ok) throw new Error('Refresh failed')
+        const data = await res.json()
+        return data.access_token
+    })()
+    
+    try {
+        return await refreshPromise
+    } finally {
+        refreshPromise = null
+    }
+}
+
+async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
+    const token = getAccessToken()
+    const headers = { 'Content-Type': 'application/json', ...options.headers }
+    if (token) headers['Authorization'] = `Bearer ${token}`
+
+    const res = await fetch(`${API_URL}${path}`, { ...options, headers, credentials: 'include' })
+
+    if (res.status === 401 && !path.includes('/auth/')) {
+        // Автоматический refresh и retry
+        const newToken = await refreshAccessToken()
+        headers['Authorization'] = `Bearer ${newToken}`
+        const retryRes = await fetch(`${API_URL}${path}`, { ...options, headers, credentials: 'include' })
+        if (!retryRes.ok) throw await parseError(retryRes)
+        return retryRes.json()
+    }
+
+    if (!res.ok) throw await parseError(res)
+    return res.json()
+}
+```
+
+### 2. HttpOnly куки для refresh token (безопаснее localStorage)
+
+**Backend изменения** (в `auth.go`):
+- При login/register: `Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`
+- При refresh: читать из куки, выдавать новый access + ротировать refresh
+- При logout: `Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`
+
+**Frontend**: убрать `localStorage` для refresh, использовать `credentials: 'include'`
+
+### 3. Типизированные ошибки по эндпоинтам
+
+```typescript
+// frontend/src/types/api.ts
+export interface ApiErrorResponse {
+    type: string
+    title: string
+    status: number
+    detail: string
+    errors?: { field: string; message: string }[]
+}
+
+export class ApiError extends Error {
+    constructor(public response: ApiErrorResponse) {
+        super(response.detail)
+        this.name = 'ApiError'
+    }
+}
+```
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|--------|-------|--------|
+| **Улучшенный fetch wrapper (рекомендую)** | Без зависимостей, полный контроль | Нужно писать boilerplate |
+| **TanStack Query (React Query)** | Кэширование, deduping, retry, devtools | Зависимость, кривая обучения |
+| **SWR** | Легкий, кэширование | Меньше фич чем TanStack Query |
+| **Axios** | Interceptors, привычный API | Устаревший, тяжелый, не нужен в 2024 |
+
+## Связанные заметки
+
+- [[backend-auth-security]] — HttpOnly куки для refresh token
+- [[frontend-auth-patterns]]
+- [[architecture-overview]]
+
+## Источник
+
+Задача: Code review PhotoPlaces — улучшение API клиента и auth flow
+
+## Теги
+
+#frontend #api-client #auth #react #typescript #best-practice