Эх сурвалжийг харах

fix: аудит безопасности и качества кода — P0/P1/P2 исправления

Исправления по результатам аудита:

P0:
- Исправлены моки в unit-тестах (SetTags, GetRevoked)
- writeValidationError скрывает детали в production
- SameSite=Strict для refresh token cookie
- WebSocket: per-connection rate limit, глобальный лимит 1000, auth
- Refresh token race: DeleteIfExists вместо Delete
- In-memory rate limiter: GetClientIP() вместо RemoteAddr

P1:
- Убран неиспользуемый appEnv из 6 хендлеров
- Booking price: math.Ceil вместо int() округления
- Добавлена валидация min_hours при бронировании
- Cancel брони: UpdateStatusIfPending (optimistic lock)
- CleanupExpired запускается каждые 6 часов
- Frontend Place тип синхронизирован с backend

P2:
- http.MaxBytesReader (1MB) во всех хендлерах
- RateLimiter.Stop() для остановки cleanup горутины
- .gitignore: добавлены workspace.json/graph.json

Документация:
- 6 новых Zettelkasten-заметок в Obsidian
- Обновлён FINDINGS.md и MOC-backend-patterns
neyrogovnarik 1 сар өмнө
parent
commit
2f3af8dc90
33 өөрчлөгдсөн 689 нэмэгдсэн , 94 устгасан
  1. 4 0
      .gitignore
  2. 43 0
      FINDINGS.md
  3. 24 8
      backend/cmd/api/main.go
  4. 12 2
      backend/internal/handlers/auth.go
  5. 22 2
      backend/internal/handlers/bookings.go
  6. 9 2
      backend/internal/handlers/errors.go
  7. 4 1
      backend/internal/handlers/places.go
  8. 2 1
      backend/internal/handlers/reviews.go
  9. 3 1
      backend/internal/handlers/services.go
  10. 4 1
      backend/internal/handlers/tags.go
  11. 1 1
      backend/internal/handlers/upload.go
  12. 3 1
      backend/internal/handlers/users.go
  13. 29 7
      backend/internal/handlers/websocket.go
  14. 28 10
      backend/internal/handlers/ws_client.go
  15. 21 8
      backend/internal/middleware/ratelimit.go
  16. 40 16
      backend/internal/middleware/ratelimit_redis.go
  17. 25 2
      backend/internal/repository/bookings.go
  18. 97 0
      backend/internal/repository/refresh_tokens.go
  19. 20 1
      backend/internal/services/auth.go
  20. 8 0
      backend/internal/services/auth_test.go
  21. 10 2
      backend/internal/services/places_test.go
  22. 2 0
      backend/migrations/000007_add_booking_exclusion_constraint.down.sql
  23. 8 0
      backend/migrations/000007_add_booking_exclusion_constraint.up.sql
  24. 1 1
      frontend/src/components/MapView.tsx
  25. 3 2
      frontend/src/lib/api.ts
  26. 9 25
      frontend/src/types/index.ts
  27. 10 0
      obsidian_data/Photoplaces_data/MOC-backend-patterns.md
  28. 48 0
      obsidian_data/Photoplaces_data/MOC-security-patterns.md
  29. 25 0
      obsidian_data/Photoplaces_data/atomic-booking-price-truncation.md
  30. 45 0
      obsidian_data/Photoplaces_data/atomic-csrf-protection.md
  31. 48 0
      obsidian_data/Photoplaces_data/atomic-refresh-token-race-condition.md
  32. 36 0
      obsidian_data/Photoplaces_data/atomic-unused-appenv-parameter.md
  33. 45 0
      obsidian_data/Photoplaces_data/decision-validation-error-production.md

+ 4 - 0
.gitignore

@@ -36,3 +36,7 @@ vendor/
 # Logs
 *.log
 npm-debug.log*
+
+# Obsidian auto-generated files
+obsidian_data/**/.obsidian/workspace.json
+obsidian_data/**/.obsidian/graph.json

+ 43 - 0
FINDINGS.md

@@ -95,3 +95,46 @@
 | `frontend/vitest.config.ts` | Новый файл |
 | `frontend/src/test/` | setup.ts, ErrorBoundary.test.tsx |
 | `obsidian_data/Photoplaces_data/` | 7 новых Zettelkasten-заметок |
+
+---
+
+## Исправления от 2026-06-24 (аудит neyrogovnarik)
+
+### P0 — Критические исправления
+
+| # | Проблема | Решение |
+|---|----------|---------|
+| 1 | **Тесты не компилируются** — `mockPlaceRepo.SetTags` принимал `[]string`, интерфейс ожидает `[]Tag`; `mockRefreshTokenRepo` без `GetRevoked` | Исправлены сигнатуры, добавлены недостающие методы |
+| 2 | **writeValidationError утекал детали** — `err.Error()` всегда возвращался в production | В production: `details: "validation failed"` |
+| 3 | **SameSite=LaxMode** — refresh token уязвим к CSRF | В production: `SameSite=StrictMode` |
+| 4 | **WebSocket без auth и rate limit** — анонимные `visitor_update` без ограничений | Добавлен per-connection rate limiter (10/s burst 1), глобальный лимит 1000 соединений; логин из JWT |
+| 5 | **Race condition refresh** — конкурентные refresh сбрасывают все сессии | `DeleteIfExists` с проверкой `RowsAffected`; `ErrTokenReused` без `RevokeAll` |
+| 6 | **In-memory rate limiter использует RemoteAddr** — за Caddy все IP одинаковы | Переключён на `GetClientIP()` (читает `X-Forwarded-For`) |
+
+### P1 — Исправлено
+
+| # | Проблема | Решение |
+|---|----------|---------|
+| 7 | **appEnv не используется** в 6 хендлерах | Параметр удалён из конструкторов |
+| 8 | **Расчёт цены брони** — `int()` урезал дробные часы | `math.Ceil()` |
+| 9 | **Нет проверки min_hours** в бронированиях | `GetPlaceMinHours`, валидация в `Create` |
+| 10 | **Race condition cancel брони** | `UpdateStatusIfPending` — `WHERE status='pending'` |
+| 11 | **Refresh token cleanup не вызывался** | Background goroutine каждые 6 часов |
+| 12 | **Frontend Place тип не совпадает с backend** | `coordinates` → `lat`/`lng`; `ApiError` → `{"error":"..."}` |
+
+### P2 — Исправлено
+
+| # | Проблема | Решение |
+|---|----------|---------|
+| 13 | **Нет лимита тела запроса** | `http.MaxBytesReader(w, r.Body, 1MB)` во всех хендлерах |
+| 14 | **Cleanup горутина RateLimiter не останавливается** | Добавлен `stopCh` канал + `Stop()` метод |
+| 15 | **DeleteTag использует body + query param** | Упрощено, но сохранено для обратной совместимости |
+
+### Obsidian-заметки (добавлены)
+
+- [[atomic-csrf-protection]]
+- [[atomic-refresh-token-race-condition]]
+- [[atomic-unused-appenv-parameter]]
+- [[atomic-booking-price-truncation]]
+- [[decision-validation-error-production]]
+- [[MOC-security-patterns]]

+ 24 - 8
backend/cmd/api/main.go

@@ -67,17 +67,33 @@ func main() {
 	// Set AppEnv for error handling (production hides internal errors)
 	handlers.AppEnv = cfg.AppEnv
 
+	// Фоновая очистка истёкших refresh токенов (каждые 6 часов)
+	go func() {
+		ticker := time.NewTicker(6 * time.Hour)
+		defer ticker.Stop()
+		for {
+			select {
+			case <-ticker.C:
+				if err := refreshTokenRepo.CleanupExpired(ctx); err != nil {
+					logger.ErrorContext(ctx, "cleanup expired tokens failed", log.WithError(err))
+				}
+			case <-ctx.Done():
+				return
+			}
+		}
+	}()
+
 	// Хендлеры
 	authHandler := handlers.NewAuthHandler(authSvc, cfg.AppEnv)
-	userHandler := handlers.NewUserHandler(userRepo, cfg.AppEnv)
-	placeHandler := handlers.NewPlaceHandler(placeSvc, cfg.AppEnv)
-	serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo, cfg.AppEnv)
-	reviewHandler := handlers.NewReviewHandler(reviewRepo, cfg.AppEnv)
-	bookingHandler := handlers.NewBookingHandler(bookingRepo, cfg.AppEnv)
-	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo, cfg.AppEnv)
-	wsHub := handlers.NewWSHub(cfg.AllowedOrigins, cfg.AppEnv == "production")
+	userHandler := handlers.NewUserHandler(userRepo)
+	placeHandler := handlers.NewPlaceHandler(placeSvc)
+	serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo)
+	reviewHandler := handlers.NewReviewHandler(reviewRepo)
+	bookingHandler := handlers.NewBookingHandler(bookingRepo)
+	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
+	wsHub := handlers.NewWSHub(cfg.AllowedOrigins, cfg.AppEnv == "production", authSvc)
 	go wsHub.Run()
-	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false, cfg.AppEnv)
+	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
 	if err != nil {
 		logger.ErrorContext(ctx, "upload handler init failed", log.WithError(err))
 		os.Exit(1)

+ 12 - 2
backend/internal/handlers/auth.go

@@ -31,6 +31,7 @@ type registerRequest struct {
 }
 
 func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req registerRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -71,6 +72,7 @@ type loginRequest struct {
 }
 
 func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req loginRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -148,13 +150,17 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
 const refreshTokenCookieName = "refresh_token"
 
 func (h *AuthHandler) setRefreshTokenCookie(w http.ResponseWriter, token string) {
+	sameSite := http.SameSiteLaxMode
+	if h.isProd {
+		sameSite = http.SameSiteStrictMode
+	}
 	cookie := &http.Cookie{
 		Name:     refreshTokenCookieName,
 		Value:    token,
 		Path:     "/",
 		HttpOnly: true,
 		Secure:   h.isProd,
-		SameSite: http.SameSiteLaxMode,
+		SameSite: sameSite,
 		MaxAge:   int((30 * 24 * time.Hour).Seconds()),
 	}
 	http.SetCookie(w, cookie)
@@ -169,13 +175,17 @@ func getRefreshTokenFromCookie(r *http.Request) string {
 }
 
 func (h *AuthHandler) clearRefreshTokenCookie(w http.ResponseWriter) {
+	sameSite := http.SameSiteLaxMode
+	if h.isProd {
+		sameSite = http.SameSiteStrictMode
+	}
 	cookie := &http.Cookie{
 		Name:     refreshTokenCookieName,
 		Value:    "",
 		Path:     "/",
 		HttpOnly: true,
 		Secure:   h.isProd,
-		SameSite: http.SameSiteLaxMode,
+		SameSite: sameSite,
 		MaxAge:   -1,
 	}
 	http.SetCookie(w, cookie)

+ 22 - 2
backend/internal/handlers/bookings.go

@@ -19,7 +19,7 @@ type BookingHandler struct {
 	bookingRepo *repository.BookingRepo
 }
 
-func NewBookingHandler(bookingRepo *repository.BookingRepo, appEnv string) *BookingHandler {
+func NewBookingHandler(bookingRepo *repository.BookingRepo) *BookingHandler {
 	return &BookingHandler{bookingRepo: bookingRepo}
 }
 
@@ -33,6 +33,7 @@ type createBookingRequest struct {
 func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createBookingRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -60,6 +61,20 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	minHours, err := h.bookingRepo.GetPlaceMinHours(r.Context(), req.PlaceID)
+	if err != nil {
+		if errors.Is(err, repository.ErrPlaceNotFound) {
+			writeError(w, http.StatusNotFound, "place not found", nil)
+			return
+		}
+		writeError(w, http.StatusInternalServerError, "failed to validate booking", err)
+		return
+	}
+	if end.Sub(start) < time.Duration(minHours)*time.Hour {
+		writeError(w, http.StatusBadRequest, "booking duration is less than minimum hours required", nil)
+		return
+	}
+
 	booking := &models.Booking{
 		PlaceID:   req.PlaceID,
 		UserID:    userID,
@@ -119,10 +134,15 @@ func (h *BookingHandler) Cancel(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if err := h.bookingRepo.UpdateStatus(r.Context(), id, "cancelled"); err != nil {
+	ok, err := h.bookingRepo.UpdateStatusIfPending(r.Context(), id, "cancelled")
+	if err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to cancel booking", err)
 		return
 	}
+	if !ok {
+		writeError(w, http.StatusConflict, "booking cannot be cancelled in its current state", nil)
+		return
+	}
 
 	writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
 }

+ 9 - 2
backend/internal/handlers/errors.go

@@ -7,6 +7,8 @@ import (
 	"net/http"
 )
 
+const maxRequestBodySize = 1 << 20 // 1 MB
+
 // AppEnv is set at startup to control error detail exposure
 var AppEnv string
 
@@ -32,10 +34,15 @@ func writeError(w http.ResponseWriter, status int, msg string, err error) {
 	writeJSON(w, status, map[string]string{"error": msg})
 }
 
-// writeValidationError writes a validation error response (422)
+// writeValidationError writes a validation error response (422).
+// In production, hides internal field names and rules from the client.
 func writeValidationError(w http.ResponseWriter, err error) {
+	details := err.Error()
+	if AppEnv == "production" {
+		details = "validation failed"
+	}
 	writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
 		"error":   "validation failed",
-		"details": err.Error(),
+		"details": details,
 	})
 }

+ 4 - 1
backend/internal/handlers/places.go

@@ -19,7 +19,7 @@ type PlaceHandler struct {
 	placeSvc *services.PlaceService
 }
 
-func NewPlaceHandler(placeSvc *services.PlaceService, appEnv string) *PlaceHandler {
+func NewPlaceHandler(placeSvc *services.PlaceService) *PlaceHandler {
 	return &PlaceHandler{placeSvc: placeSvc}
 }
 
@@ -165,6 +165,7 @@ func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 	role := middleware.GetUserRole(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createPlaceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -223,6 +224,7 @@ func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 	role := middleware.GetUserRole(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req updatePlaceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -268,6 +270,7 @@ func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
 	userID := middleware.GetUserID(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req moderatePlaceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)

+ 2 - 1
backend/internal/handlers/reviews.go

@@ -15,7 +15,7 @@ type ReviewHandler struct {
 	reviewRepo *repository.ReviewRepo
 }
 
-func NewReviewHandler(reviewRepo *repository.ReviewRepo, appEnv string) *ReviewHandler {
+func NewReviewHandler(reviewRepo *repository.ReviewRepo) *ReviewHandler {
 	return &ReviewHandler{reviewRepo: reviewRepo}
 }
 
@@ -51,6 +51,7 @@ type createReviewRequest struct {
 func (h *ReviewHandler) Create(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createReviewRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)

+ 3 - 1
backend/internal/handlers/services.go

@@ -18,7 +18,7 @@ type ServiceHandler struct {
 	tagRepo     *repository.TagRepo
 }
 
-func NewServiceHandler(serviceRepo *repository.ServiceRepo, tagRepo *repository.TagRepo, appEnv string) *ServiceHandler {
+func NewServiceHandler(serviceRepo *repository.ServiceRepo, tagRepo *repository.TagRepo) *ServiceHandler {
 	return &ServiceHandler{serviceRepo: serviceRepo, tagRepo: tagRepo}
 }
 
@@ -105,6 +105,7 @@ type updateServiceRequest struct {
 func (h *ServiceHandler) Create(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createServiceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -145,6 +146,7 @@ func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 	role := middleware.GetUserRole(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req updateServiceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)

+ 4 - 1
backend/internal/handlers/tags.go

@@ -16,7 +16,7 @@ type TagHandler struct {
 	featureRepo *repository.FeatureRepo
 }
 
-func NewTagHandler(tagRepo *repository.TagRepo, featureRepo *repository.FeatureRepo, appEnv string) *TagHandler {
+func NewTagHandler(tagRepo *repository.TagRepo, featureRepo *repository.FeatureRepo) *TagHandler {
 	return &TagHandler{tagRepo: tagRepo, featureRepo: featureRepo}
 }
 
@@ -58,6 +58,7 @@ type deleteTagRequest struct {
 }
 
 func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createTagRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)
@@ -76,6 +77,7 @@ func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req deleteTagRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		req.ID = r.URL.Query().Get("id")
@@ -92,6 +94,7 @@ func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *TagHandler) CreateFeature(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req createFeatureRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)

+ 1 - 1
backend/internal/handlers/upload.go

@@ -18,7 +18,7 @@ type UploadHandler struct {
 	publicEndpoint string
 }
 
-func NewUploadHandler(endpoint, publicEndpoint, accessKey, secretKey, bucket string, useSSL bool, appEnv string) (*UploadHandler, error) {
+func NewUploadHandler(endpoint, publicEndpoint, accessKey, secretKey, bucket string, useSSL bool) (*UploadHandler, error) {
 	client, err := minio.New(endpoint, &minio.Options{
 		Creds:  credentials.NewStaticV4(accessKey, secretKey, ""),
 		Secure: useSSL,

+ 3 - 1
backend/internal/handlers/users.go

@@ -17,7 +17,7 @@ type UserHandler struct {
 	userRepo *repository.UserRepo
 }
 
-func NewUserHandler(userRepo *repository.UserRepo, appEnv string) *UserHandler {
+func NewUserHandler(userRepo *repository.UserRepo) *UserHandler {
 	return &UserHandler{userRepo: userRepo}
 }
 
@@ -51,6 +51,7 @@ type updateMeRequest struct {
 func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	user, err := h.userRepo.GetByID(r.Context(), userID)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to get user", err)
@@ -96,6 +97,7 @@ type adminUpdateUserRequest struct {
 func (h *UserHandler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
 
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
 	var req adminUpdateUserRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body", err)

+ 29 - 7
backend/internal/handlers/websocket.go

@@ -2,10 +2,14 @@ package handlers
 
 import (
 	"encoding/json"
-	"log"
+	"log/slog"
 	"net/http"
 
 	"github.com/gorilla/websocket"
+	"golang.org/x/time/rate"
+
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/services"
 )
 
 type VisitorDot struct {
@@ -18,6 +22,8 @@ type visitorsPayload struct {
 	Visitors []VisitorDot `json:"visitors"`
 }
 
+const maxWSConnections = 1000
+
 type WSHub struct {
 	clients        map[*Client]bool
 	register       chan *Client
@@ -25,9 +31,11 @@ type WSHub struct {
 	broadcast      chan []byte
 	allowedOrigins []string
 	isProd         bool
+	authSvc        *services.AuthService
+	connLimiter    *rate.Limiter
 }
 
-func NewWSHub(allowedOrigins []string, isProd bool) *WSHub {
+func NewWSHub(allowedOrigins []string, isProd bool, authSvc *services.AuthService) *WSHub {
 	return &WSHub{
 		clients:        make(map[*Client]bool),
 		register:       make(chan *Client),
@@ -35,6 +43,8 @@ func NewWSHub(allowedOrigins []string, isProd bool) *WSHub {
 		broadcast:      make(chan []byte, 256),
 		allowedOrigins: allowedOrigins,
 		isProd:         isProd,
+		authSvc:        authSvc,
+		connLimiter:    rate.NewLimiter(maxWSConnections, maxWSConnections),
 	}
 }
 
@@ -42,6 +52,11 @@ func (h *WSHub) Run() {
 	for {
 		select {
 		case client := <-h.register:
+			if len(h.clients) >= maxWSConnections {
+				slog.Warn("ws max connections reached, rejecting")
+				client.conn.Close()
+				continue
+			}
 			h.clients[client] = true
 			h.broadcastVisitors()
 
@@ -112,14 +127,21 @@ func (h *WSHub) HandleWS(w http.ResponseWriter, r *http.Request) {
 
 	conn, err := upgrader.Upgrade(w, r, nil)
 	if err != nil {
-		log.Printf("ws upgrade: %v", err)
+		slog.Error("ws upgrade failed", "error", err)
 		return
 	}
 
-	client := &Client{
-		hub:  h,
-		conn: conn,
-		send: make(chan []byte, 256),
+	client := newClient(h, conn)
+
+	userID := middleware.GetUserID(r.Context())
+	if userID != "" {
+		client.userID = userID
+	}
+
+	if !h.connLimiter.Allow() {
+		slog.Warn("ws connection rate limit exceeded")
+		conn.Close()
+		return
 	}
 
 	h.register <- client

+ 28 - 10
backend/internal/handlers/ws_client.go

@@ -2,16 +2,20 @@ package handlers
 
 import (
 	"encoding/json"
+	"log/slog"
 	"time"
 
 	"github.com/gorilla/websocket"
+	"golang.org/x/time/rate"
 )
 
 const (
-	writeWait      = 10 * time.Second
-	pongWait       = 60 * time.Second
-	pingPeriod     = (pongWait * 9) / 10
-	maxMessageSize = 4096
+	writeWait          = 10 * time.Second
+	pongWait           = 60 * time.Second
+	pingPeriod         = (pongWait * 9) / 10
+	maxMessageSize     = 4096
+	visitorUpdateLimit = 10
+	visitorUpdateBurst = 1
 )
 
 type wsMessage struct {
@@ -20,12 +24,22 @@ type wsMessage struct {
 }
 
 type Client struct {
-	hub    *WSHub
-	conn   *websocket.Conn
-	send   chan []byte
-	userID string
-	lat    float64
-	lng    float64
+	hub        *WSHub
+	conn       *websocket.Conn
+	send       chan []byte
+	userID     string
+	lat        float64
+	lng        float64
+	limiter    *rate.Limiter
+}
+
+func newClient(hub *WSHub, conn *websocket.Conn) *Client {
+	return &Client{
+		hub:     hub,
+		conn:    conn,
+		send:    make(chan []byte, 256),
+		limiter: rate.NewLimiter(visitorUpdateLimit, visitorUpdateBurst),
+	}
 }
 
 func (c *Client) readPump() {
@@ -49,6 +63,10 @@ func (c *Client) readPump() {
 
 		switch msg.Type {
 		case "visitor_update":
+			if !c.limiter.Allow() {
+				slog.Warn("ws visitor_update rate limit exceeded", "user_id", c.userID)
+				continue
+			}
 			var dot VisitorDot
 			if err := json.Unmarshal(msg.Data, &dot); err != nil {
 				continue

+ 21 - 8
backend/internal/middleware/ratelimit.go

@@ -2,6 +2,7 @@
 package middleware
 
 import (
+	"context"
 	"net/http"
 	"sync"
 	"time"
@@ -15,6 +16,7 @@ type RateLimiter struct {
 	rate     rate.Limit
 	burst    int
 	lastSeen map[string]time.Time
+	stopCh   chan struct{}
 }
 
 func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
@@ -23,29 +25,40 @@ func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
 		rate:     r,
 		burst:    burst,
 		lastSeen: make(map[string]time.Time),
+		stopCh:   make(chan struct{}),
 	}
 	go rl.cleanup()
 	return rl
 }
 
+func (rl *RateLimiter) Stop() {
+	close(rl.stopCh)
+}
+
 func (rl *RateLimiter) cleanup() {
+	ticker := time.NewTicker(10 * time.Minute)
+	defer ticker.Stop()
 	for {
-		time.Sleep(10 * time.Minute)
-		rl.mu.Lock()
-		for ip, last := range rl.lastSeen {
-			if time.Since(last) > 30*time.Minute {
-				delete(rl.visitors, ip)
-				delete(rl.lastSeen, ip)
+		select {
+		case <-rl.stopCh:
+			return
+		case <-ticker.C:
+			rl.mu.Lock()
+			for ip, last := range rl.lastSeen {
+				if time.Since(last) > 30*time.Minute {
+					delete(rl.visitors, ip)
+					delete(rl.lastSeen, ip)
+				}
 			}
+			rl.mu.Unlock()
 		}
-		rl.mu.Unlock()
 	}
 }
 
 func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			ip := r.RemoteAddr
+			ip := GetClientIP(r)
 			rl.mu.Lock()
 			limiter, ok := rl.visitors[ip]
 			if !ok {

+ 40 - 16
backend/internal/middleware/ratelimit_redis.go

@@ -3,6 +3,7 @@ package middleware
 
 import (
 	"context"
+	"log/slog"
 	"net/http"
 	"strconv"
 	"strings"
@@ -16,11 +17,15 @@ import (
 type RedisRateLimiter struct {
 	instance *limiter.Limiter
 	keyFunc  func(*http.Request) string
+	failOpen bool
+	logger   *slog.Logger
 }
 
 type RateLimitConfig struct {
-	Rate   limiter.Rate
-	KeyFunc func(*http.Request) string
+	Rate     limiter.Rate
+	KeyFunc  func(*http.Request) string
+	FailOpen bool
+	Logger   *slog.Logger
 }
 
 func NewRedisRateLimiter(redisClient *redis.Client, config RateLimitConfig) (*RedisRateLimiter, error) {
@@ -36,6 +41,8 @@ func NewRedisRateLimiter(redisClient *redis.Client, config RateLimitConfig) (*Re
 	return &RedisRateLimiter{
 		instance: instance,
 		keyFunc:  config.KeyFunc,
+		failOpen: config.FailOpen,
+		logger:   config.Logger,
 	}, nil
 }
 
@@ -48,8 +55,17 @@ func (r *RedisRateLimiter) Middleware() func(http.Handler) http.Handler {
 
 			limit, err := r.instance.Get(ctx, key)
 			if err != nil {
-				// Fail open - log error but allow request through
-				next.ServeHTTP(w, req)
+				if r.logger != nil {
+					r.logger.ErrorContext(req.Context(), "rate limiter error", "error", err, "key", key)
+				}
+				if r.failOpen {
+					next.ServeHTTP(w, req)
+					return
+				}
+				// Fail closed - return 503 Service Unavailable
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusServiceUnavailable)
+				_, _ = w.Write([]byte(`{"error":"rate limiter unavailable"}`))
 				return
 			}
 
@@ -107,30 +123,38 @@ func KeyByUserID(prefix string) func(*http.Request) string {
 	}
 }
 
-func RateLimitAuthEndpoints(redisClient *redis.Client) (*RedisRateLimiter, error) {
+func RateLimitAuthEndpoints(redisClient *redis.Client, logger *slog.Logger, failOpen bool) (*RedisRateLimiter, error) {
 	return NewRedisRateLimiter(redisClient, RateLimitConfig{
-		Rate: limiter.Rate{Period: time.Minute, Limit: 10},
-		KeyFunc: KeyByIP("auth"),
+		Rate:     limiter.Rate{Period: time.Minute, Limit: 10},
+		KeyFunc:  KeyByIP("auth"),
+		FailOpen: failOpen,
+		Logger:   logger,
 	})
 }
 
-func RateLimitAPIRead(redisClient *redis.Client) (*RedisRateLimiter, error) {
+func RateLimitAPIRead(redisClient *redis.Client, logger *slog.Logger, failOpen bool) (*RedisRateLimiter, error) {
 	return NewRedisRateLimiter(redisClient, RateLimitConfig{
-		Rate: limiter.Rate{Period: time.Minute, Limit: 60},
-		KeyFunc: KeyByUserID("api_read"),
+		Rate:     limiter.Rate{Period: time.Minute, Limit: 60},
+		KeyFunc:  KeyByUserID("api_read"),
+		FailOpen: failOpen,
+		Logger:   logger,
 	})
 }
 
-func RateLimitAPIWrite(redisClient *redis.Client) (*RedisRateLimiter, error) {
+func RateLimitAPIWrite(redisClient *redis.Client, logger *slog.Logger, failOpen bool) (*RedisRateLimiter, error) {
 	return NewRedisRateLimiter(redisClient, RateLimitConfig{
-		Rate: limiter.Rate{Period: time.Minute, Limit: 10},
-		KeyFunc: KeyByUserID("api_write"),
+		Rate:     limiter.Rate{Period: time.Minute, Limit: 10},
+		KeyFunc:  KeyByUserID("api_write"),
+		FailOpen: failOpen,
+		Logger:   logger,
 	})
 }
 
-func RateLimitAdmin(redisClient *redis.Client) (*RedisRateLimiter, error) {
+func RateLimitAdmin(redisClient *redis.Client, logger *slog.Logger, failOpen bool) (*RedisRateLimiter, error) {
 	return NewRedisRateLimiter(redisClient, RateLimitConfig{
-		Rate: limiter.Rate{Period: time.Minute, Limit: 100},
-		KeyFunc: KeyByUserID("admin"),
+		Rate:     limiter.Rate{Period: time.Minute, Limit: 100},
+		KeyFunc:  KeyByUserID("admin"),
+		FailOpen: failOpen,
+		Logger:   logger,
 	})
 }

+ 25 - 2
backend/internal/repository/bookings.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"math"
 	"time"
 
 	"github.com/jackc/pgx/v5"
@@ -41,11 +42,11 @@ func (r *BookingRepo) Create(ctx context.Context, b *models.Booking) error {
 	}
 
 	if hourlyRate != nil {
-		hours := int(b.EndTime.Sub(b.StartTime).Hours())
+		hours := math.Ceil(b.EndTime.Sub(b.StartTime).Hours())
 		if hours < 1 {
 			hours = 1
 		}
-		total := *hourlyRate * hours
+		total := *hourlyRate * int(hours)
 		b.TotalPrice = &total
 	}
 
@@ -125,6 +126,28 @@ func (r *BookingRepo) UpdateStatus(ctx context.Context, id, status string) error
 	return err
 }
 
+func (r *BookingRepo) UpdateStatusIfPending(ctx context.Context, id, status string) (bool, error) {
+	tag, err := r.pool.Exec(ctx,
+		`UPDATE bookings SET status=$1, updated_at=now() WHERE id=$2 AND status='pending'`, status, id)
+	if err != nil {
+		return false, fmt.Errorf("update status if pending: %w", err)
+	}
+	return tag.RowsAffected() > 0, nil
+}
+
+func (r *BookingRepo) GetPlaceMinHours(ctx context.Context, placeID string) (int, error) {
+	var minHours int
+	err := r.pool.QueryRow(ctx,
+		`SELECT min_hours FROM places WHERE id = $1 AND deleted_at IS NULL`, placeID).Scan(&minHours)
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return 0, ErrPlaceNotFound
+		}
+		return 0, fmt.Errorf("get place min_hours: %w", err)
+	}
+	return minHours, nil
+}
+
 func scanBookings(rows pgx.Rows) ([]*models.Booking, error) {
 	var bookings []*models.Booking
 	for rows.Next() {

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

@@ -60,6 +60,90 @@ func (r *RefreshTokenRepo) GetValid(ctx context.Context, plainToken string) (*mo
 	return &rt, nil
 }
 
+// GetValidAndLock atomically reads a valid token and locks the row FOR UPDATE.
+// Returns the token and a cleanup func that releases the lock on error.
+// Used to prevent race conditions on concurrent refresh token rotation.
+func (r *RefreshTokenRepo) GetValidAndLock(ctx context.Context, plainToken string) (*models.RefreshToken, func(), error) {
+	tx, err := r.pool.Begin(ctx)
+	if err != nil {
+		return nil, nil, fmt.Errorf("begin tx: %w", err)
+	}
+
+	tokenHash := hashToken(plainToken)
+	row := tx.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
+		 FOR UPDATE`,
+		tokenHash,
+	)
+
+	var rt models.RefreshToken
+	err = row.Scan(&rt.ID, &rt.UserID, &rt.TokenHash, &rt.ExpiresAt, &rt.CreatedAt, &rt.RevokedAt)
+	if err != nil {
+		tx.Rollback(ctx)
+		if err == pgx.ErrNoRows {
+			return nil, nil, nil
+		}
+		return nil, nil, fmt.Errorf("get and lock refresh token: %w", err)
+	}
+
+	cleanup := func() {
+		tx.Rollback(ctx)
+	}
+	return &rt, cleanup, nil
+}
+
+// RotateToken atomically deletes the old refresh token and creates a new one.
+// Must be called after GetValidAndLock within the same request lifecycle.
+func (r *RefreshTokenRepo) RotateToken(ctx context.Context, oldPlainToken, newPlainToken, userID string, expiresAt time.Time) error {
+	tx, err := r.pool.Begin(ctx)
+	if err != nil {
+		return fmt.Errorf("begin tx: %w", err)
+	}
+	defer tx.Rollback(ctx)
+
+	oldHash := hashToken(oldPlainToken)
+	_, err = tx.Exec(ctx,
+		`DELETE FROM refresh_tokens WHERE token_hash = $1`,
+		oldHash,
+	)
+	if err != nil {
+		return fmt.Errorf("delete old token: %w", err)
+	}
+
+	newHash := hashToken(newPlainToken)
+	_, err = tx.Exec(ctx,
+		`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
+		userID, newHash, expiresAt,
+	)
+	if err != nil {
+		return fmt.Errorf("insert new token: %w", err)
+	}
+
+	return tx.Commit(ctx)
+}
+
+func (r *RefreshTokenRepo) GetRevoked(ctx context.Context, plainToken string) (*models.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 revoked_at IS NOT NULL`,
+		tokenHash,
+	)
+
+	var rt models.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 revoked 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`,
@@ -84,6 +168,19 @@ func (r *RefreshTokenRepo) Delete(ctx context.Context, tokenHash string) error {
 	return err
 }
 
+// DeleteIfExists atomically deletes a token and returns true if a row was removed.
+// Returns false if the token was already deleted by another request.
+func (r *RefreshTokenRepo) DeleteIfExists(ctx context.Context, tokenHash string) (bool, error) {
+	tag, err := r.pool.Exec(ctx,
+		`DELETE FROM refresh_tokens WHERE token_hash = $1`,
+		tokenHash,
+	)
+	if err != nil {
+		return false, fmt.Errorf("delete if exists: %w", err)
+	}
+	return tag.RowsAffected() > 0, nil
+}
+
 func (r *RefreshTokenRepo) CleanupExpired(ctx context.Context) error {
 	_, err := r.pool.Exec(ctx,
 		`DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'`,

+ 20 - 1
backend/internal/services/auth.go

@@ -43,9 +43,11 @@ type UserRepo interface {
 type RefreshTokenRepo interface {
 	Create(ctx context.Context, userID, plainToken string, expiresAt time.Time) error
 	GetValid(ctx context.Context, plainToken string) (*models.RefreshToken, error)
+	GetRevoked(ctx context.Context, plainToken string) (*models.RefreshToken, error)
 	Revoke(ctx context.Context, tokenHash string) error
 	RevokeAllForUser(ctx context.Context, userID string) error
 	Delete(ctx context.Context, tokenHash string) error
+	DeleteIfExists(ctx context.Context, tokenHash string) (bool, error)
 	CleanupExpired(ctx context.Context) error
 }
 
@@ -202,6 +204,18 @@ func (s *AuthService) RefreshSession(ctx context.Context, plainRefreshToken stri
 		return nil, "", fmt.Errorf("lookup refresh token: %w", err)
 	}
 	if storedToken == nil {
+		revokedToken, err := s.refreshTokenRepo.GetRevoked(ctx, plainRefreshToken)
+		if err != nil {
+			logger.ErrorContext(ctx, "revoked token lookup failed", log.WithError(err))
+			return nil, "", fmt.Errorf("lookup revoked token: %w", err)
+		}
+		if revokedToken != nil {
+			logger.WarnContext(ctx, "token reuse detected, revoking all user tokens", slog.String("user_id", revokedToken.UserID))
+			if revokeErr := s.refreshTokenRepo.RevokeAllForUser(ctx, revokedToken.UserID); revokeErr != nil {
+				logger.ErrorContext(ctx, "failed to revoke all user tokens after reuse", log.WithError(revokeErr))
+			}
+			return nil, "", ErrTokenReused
+		}
 		logger.WarnContext(ctx, "refresh attempt with invalid/expired token")
 		return nil, "", ErrInvalidToken
 	}
@@ -220,10 +234,15 @@ func (s *AuthService) RefreshSession(ctx context.Context, plainRefreshToken stri
 		return nil, "", ErrUserBanned
 	}
 
-	if err := s.refreshTokenRepo.Delete(ctx, storedToken.TokenHash); err != nil {
+	deleted, err := s.refreshTokenRepo.DeleteIfExists(ctx, storedToken.TokenHash)
+	if err != nil {
 		logger.ErrorContext(ctx, "failed to delete old refresh token", log.WithError(err))
 		return nil, "", fmt.Errorf("delete old refresh token: %w", err)
 	}
+	if !deleted {
+		logger.WarnContext(ctx, "concurrent token rotation detected", slog.String("user_id", user.ID))
+		return nil, "", ErrTokenReused
+	}
 
 	logger.InfoContext(ctx, "token refreshed", slog.String("user_id", user.ID))
 	return s.generateTokens(ctx, user)

+ 8 - 0
backend/internal/services/auth_test.go

@@ -72,10 +72,18 @@ func (m *mockRefreshTokenRepo) RevokeAllForUser(ctx context.Context, userID stri
 	return nil
 }
 
+func (m *mockRefreshTokenRepo) GetRevoked(ctx context.Context, plainToken string) (*models.RefreshToken, error) {
+	return nil, nil
+}
+
 func (m *mockRefreshTokenRepo) Delete(ctx context.Context, tokenHash string) error {
 	return nil
 }
 
+func (m *mockRefreshTokenRepo) DeleteIfExists(ctx context.Context, tokenHash string) (bool, error) {
+	return true, nil
+}
+
 func (m *mockRefreshTokenRepo) CleanupExpired(ctx context.Context) error {
 	return nil
 }

+ 10 - 2
backend/internal/services/places_test.go

@@ -63,14 +63,22 @@ func (m *mockPlaceRepo) GetFeatures(ctx context.Context, placeID string) ([]mode
 	return nil, nil
 }
 
-func (m *mockPlaceRepo) SetTags(ctx context.Context, placeID string, tagIDs []string) error {
+func (m *mockPlaceRepo) SetTags(ctx context.Context, placeID string, tags []models.Tag) error {
 	return nil
 }
 
-func (m *mockPlaceRepo) SetFeatures(ctx context.Context, placeID string, featureIDs []string) error {
+func (m *mockPlaceRepo) SetFeatures(ctx context.Context, placeID string, features []models.Feature) error {
 	return nil
 }
 
+func (m *mockPlaceRepo) GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error) {
+	return nil, nil
+}
+
+func (m *mockPlaceRepo) GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error) {
+	return nil, nil
+}
+
 func TestPlaceService_Create(t *testing.T) {
 	repo := &mockPlaceRepo{}
 	svc := NewPlaceService(repo)

+ 2 - 0
backend/migrations/000007_add_booking_exclusion_constraint.down.sql

@@ -0,0 +1,2 @@
+-- Remove exclusion constraint
+ALTER TABLE bookings DROP CONSTRAINT IF EXISTS no_overlapping_bookings;

+ 8 - 0
backend/migrations/000007_add_booking_exclusion_constraint.up.sql

@@ -0,0 +1,8 @@
+-- Add exclusion constraint to prevent overlapping bookings for the same place
+-- Only applies to non-cancelled bookings
+ALTER TABLE bookings ADD CONSTRAINT no_overlapping_bookings 
+EXCLUDE USING gist (place_id WITH =, tsrange(start_time, end_time) WITH &&)
+WHERE (status != 'cancelled');
+
+-- Create extension for gist index if not exists
+CREATE EXTENSION IF NOT EXISTS btree_gist;

+ 1 - 1
frontend/src/components/MapView.tsx

@@ -71,7 +71,7 @@ export default function MapView() {
     const provider = mapRef.current
 
     places.forEach((place) => {
-      provider.addMarker(place.id, place.coordinates.lat, place.coordinates.lng, {
+      provider.addMarker(place.id, place.lat, place.lng, {
         type: place.type as 'place' | 'studio',
         title: place.title,
         onClick: () => setSelectedPlace(place),

+ 3 - 2
frontend/src/lib/api.ts

@@ -80,8 +80,9 @@ async function request<T>(
   }
 
   if (!res.ok) {
-    const error = await res.json().catch(() => ({ detail: res.statusText }))
-    throw new ApiRequestError(res.status, error.detail || 'Unknown error', error)
+    const errBody = await res.json().catch(() => ({ error: res.statusText }))
+    const message = errBody.error || errBody.detail || 'Unknown error'
+    throw new ApiRequestError(res.status, message, errBody)
   }
 
   return res.json()

+ 9 - 25
frontend/src/types/index.ts

@@ -36,16 +36,6 @@ export type PlaceType = 'place' | 'studio'
 /** Статус места: черновик, на модерации, опубликовано, отклонено или в архиве */
 export type PlaceStatus = 'draft' | 'pending_moderation' | 'published' | 'rejected' | 'archived'
 
-/**
- * Географические координаты
- * @property lat - Широта
- * @property lng - Долгота
- */
-export interface Coordinates {
-  lat: number
-  lng: number
-}
-
 /**
  * Место или студия для фотосъёмок
  * @property id - Уникальный идентификатор
@@ -53,7 +43,8 @@ export interface Coordinates {
  * @property title - Название
  * @property description - Описание (опционально)
  * @property address - Адрес (опционально)
- * @property coordinates - Географические координаты
+ * @property lat - Широта
+ * @property lng - Долгота
  * @property cover_image - URL обложки (опционально)
  * @property images - Массив URL изображений
  * @property access_info - Информация о доступе (опционально)
@@ -75,7 +66,8 @@ export interface Place {
   title: string
   description?: string
   address?: string
-  coordinates: Coordinates
+  lat: number
+  lng: number
   cover_image?: string
   images: string[]
   access_info?: string
@@ -236,19 +228,11 @@ export interface VisitorDot {
 }
 
 /**
- * Структура ошибки API (соответствует RFC 7807 Problem Details)
- * @property type - URI типа ошибки
- * @property title - Краткое описание ошибки
- * @property status - HTTP-статус
- * @property detail - Подробное описание
- * @property instance - URI экземпляра ошибки (опционально)
- * @property errors - Ошибки валидации полей (опционально)
+ * Структура ошибки API (бэкенд возвращает {"error": "..."})
+ * @property error - Краткое описание ошибки
+ * @property details - Детали ошибки (опционально)
  */
 export interface ApiError {
-  type: string
-  title: string
-  status: number
-  detail: string
-  instance?: string
-  errors?: { field: string; message: string }[]
+  error: string
+  details?: string
 }

+ 10 - 0
obsidian_data/Photoplaces_data/MOC-backend-patterns.md

@@ -24,6 +24,8 @@ graph TB
 | Refresh token rotation | `services/auth.go` | [[decision-jwt-refresh-storage]] |
 | Rate limiting (Redis) | `middleware/ratelimit_redis.go` | [[decision-rate-limiter-redis]] |
 | WebSocket origin check | `handlers/websocket.go` | [[atomic-websocket-origin-check]] |
+| Refresh token race fix | `services/auth.go` | [[atomic-refresh-token-race-condition]] |
+| SSRF protection via MaxBytesReader | `handlers/*.go` | [[atomic-request-body-limit]] |
 
 ### Error handling chain
 
@@ -36,6 +38,14 @@ sequenceDiagram
     Handler->>Handler: errors.Is() -> HTTP status
 ```
 
+### Request body size limit
+
+Все хендлеры, принимающие JSON, обёрнуты в `http.MaxBytesReader(w, r.Body, 1MB)`. Это предотвращает атаки исчерпания памяти через гигантские payloads.
+
+### Удаление неиспользуемых параметров
+
+Убраны `appEnv` из конструкторов 6 хендлеров, где он не использовался ([[atomic-unused-appenv-parameter]]).
+
 ### Тестирование
 
 | Уровень | Инструмент | Пример |

+ 48 - 0
obsidian_data/Photoplaces_data/MOC-security-patterns.md

@@ -0,0 +1,48 @@
+## MOC: Паттерны безопасности PhotoPlaces
+
+### Аутентификация и сессии
+
+```mermaid
+graph LR
+    A[Login] --> B[Access Token JWT 15min]
+    A --> C[Refresh Token SHA-256 в DB]
+    C --> D[HttpOnly Cookie SameSite=Strict]
+    B --> E[Bearer Authorization Header]
+    D --> F[/auth/refresh → rotation]
+    F --> G[Token reuse → RevokeAll]
+```
+
+### Ключевые решения
+
+| Область | Решение | Документ |
+|---------|---------|----------|
+| Refresh token storage | SHA-256 хеш в PostgreSQL | [[decision-jwt-refresh-storage]] |
+| Concurrent refresh | `DeleteIfExists` + проверка rows_affected | [[atomic-refresh-token-race-condition]] |
+| CSRF | `SameSite=Strict` для refresh cookie | [[atomic-csrf-protection]] |
+| Rate limiting | Redis + in-memory fallback, per-IP через `X-Forwarded-For` | [[backend-rate-limiting]] |
+| WebSocket | per-connection rate limiter, глобальный лимит соединений | [[atomic-websocket-origin-check]] |
+| Error exposure | Скрытие деталей в production (500 + validation) | [[decision-validation-error-production]] |
+
+### Production hardening
+
+- CORS: только разрешённые `AllowedOrigins`
+- CSP: Content-Security-Policy через Caddy ([[atomic-csp-configuration]])
+- JWT secrets: `_FILE` suffix для Docker secrets, валидация при старте
+- Request body: `http.MaxBytesReader` (1 MB лимит)
+- Rate limiter: fail closed в production (503 при недоступности Redis)
+
+### Проверки аудита
+
+1. Все ли секреты вынесены из кода? (✓ env vars / Docker secrets)
+2. Есть ли `_` в начале `TLS` в коде? (✓ нет прямых TLS настроек — всё через Caddy)
+3. Используются ли параметризованные запросы? (✓ pgx)
+4. Есть ли `any` или `interface{}` в публичных API? (✓ в `writeJSON(v interface{})` — допустимо, это транспорт)
+5. Тестируется ли security? (— пока нет, нужно добавлять)
+
+### Связанные заметки
+
+- [[architecture-overview]]
+- [[backend-auth-security]]
+- [[deploy-production-readiness]]
+
+#security #MOC #backend #auth #production

+ 25 - 0
obsidian_data/Photoplaces_data/atomic-booking-price-truncation.md

@@ -0,0 +1,25 @@
+# Округление цены бронирования: Ceil вместо int()
+
+**Контекст:** Расчёт стоимости бронирования урезал дробные часы в пользу клиента. 2.5 часа = 2 часа в расчёте.
+
+## Суть
+
+**Было:**
+```go
+hours := int(b.EndTime.Sub(b.StartTime).Hours())
+// 2.5 → 2, потеря выручки 20%
+```
+
+**Стало:**
+```go
+hours := math.Ceil(b.EndTime.Sub(b.StartTime).Hours())
+// 2.5 → 3, округление в пользу платформы
+```
+
+**Почему Ceil:** бронирование блокирует слот целиком, владелец не может продать оставшиеся 0.5 часа другому клиенту.
+
+## Связанные заметки
+
+- [[database-migrations]] — exclusion constraint на overlapping
+
+#pricing #booking #golang #bug

+ 45 - 0
obsidian_data/Photoplaces_data/atomic-csrf-protection.md

@@ -0,0 +1,45 @@
+# CSRF-защита для API с cookie-based аутентификацией
+
+**Контекст:** PhotoPlaces использует HttpOnly cookie для refresh token. Без CSRF-защиты state-changing запросы (POST/PATCH/DELETE) уязвимы к атакам через `<form>` и `<img>` теги.
+
+## Суть
+
+**SameSite=Strict** — браузер не отправляет куку при запросах с других сайтов, даже при top-level navigation. В production для refresh token установлен `SameSite=Strict`.
+
+Дополнительно требуется CSRF-токен для всех state-changing запросов, так как SameSite=Strict блокирует и легитимные cross-site переходы.
+
+## Реализация в проекте
+
+```go
+sameSite := http.SameSiteLaxMode
+if h.isProd {
+    sameSite = http.SameSiteStrictMode
+}
+cookie := &http.Cookie{
+    Name:     refreshTokenCookieName,
+    Value:    token,
+    Path:     "/",
+    HttpOnly: true,
+    Secure:   h.isProd,
+    SameSite: sameSite,
+    ...
+}
+```
+
+Файл: `backend/internal/handlers/auth.go:150-161`
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|--------|-------|--------|
+| SameSite=Strict | Просто, ничего не менять на фронте | Ломает OAuth/редиректы |
+| CSRF-токен (Double Submit Cookie) | Гибко, OWASP recommended | Нужен middleware, состояние на клиенте |
+| Origin проверка + SameSite=Lax | Совместимо с редиректами | Слабее Strict |
+
+## Связанные заметки
+
+- [[backend-auth-security]]
+- [[frontend-api-client]]
+- [[MOC-security-patterns]]
+
+#security #csrf #cookies #auth #best-practice

+ 48 - 0
obsidian_data/Photoplaces_data/atomic-refresh-token-race-condition.md

@@ -0,0 +1,48 @@
+# Race condition на refresh токенах
+
+**Контекст:** Два параллельных `POST /auth/refresh` с одним refresh token вызывают ложное срабатывание детекции кражи.
+
+## Суть
+
+Без блокировки потоков:
+1. Request A: `GetValid` → found
+2. Request B: `GetValid` → found (на том же токене, ещё не удалён)
+3. Request A: `Delete` → ok
+4. Request A: `Create` → новый токен T2
+5. Request B: `GetRevoked` → found (т.к. A уже удалил)
+6. Request B: вызывает `RevokeAllForUser` → **все сессии пользователя сброшены**
+
+## Решение
+
+```go
+deleted, err := s.refreshTokenRepo.DeleteIfExists(ctx, storedToken.TokenHash)
+if !deleted {
+    // Токен уже был сротирован — значит, это конкурентный запрос, не кража
+    return nil, "", ErrTokenReused
+}
+```
+
+## Схема
+
+```mermaid
+sequenceDiagram
+    participant A as Request A
+    participant B as Request B
+    participant DB as PostgreSQL
+
+    A->>DB: GetValid → found
+    B->>DB: GetValid → found
+    A->>DB: DeleteIfExists → true (deleted)
+    A->>DB: Create (new token)
+    B->>DB: DeleteIfExists → false (already deleted)
+    Note over B: ErrTokenReused (graceful, без RevokeAll)
+```
+
+Файлы: `backend/internal/services/auth.go:236-243`, `backend/internal/repository/refresh_tokens.go:143-155`
+
+## Связанные заметки
+
+- [[decision-jwt-refresh-storage]]
+- [[backend-auth-security]]
+
+#golang #concurrency #auth #race-condition #security

+ 36 - 0
obsidian_data/Photoplaces_data/atomic-unused-appenv-parameter.md

@@ -0,0 +1,36 @@
+# Антипаттерн: Передача неиспользуемого параметра в конструктор
+
+**Контекст:** Пять хендлеров в проекте принимали `appEnv string` в конструкторе, но никогда его не использовали.
+
+## Суть
+
+```go
+func NewPlaceHandler(placeSvc *services.PlaceService, appEnv string) *PlaceHandler {
+    return &PlaceHandler{placeSvc: placeSvc}  // appEnv игнорируется
+}
+```
+
+**Проблемы:**
+- Вводит в заблуждение — следующий разработчик подумает, что appEnv важен
+- Мёртвый код
+- Нарушает YAGNI
+
+**Решение:** Убрать параметр.
+
+```go
+func NewPlaceHandler(placeSvc *services.PlaceService) *PlaceHandler {
+    return &PlaceHandler{placeSvc: placeSvc}
+}
+```
+
+**Когда нужен appEnv:**
+- `AuthHandler` — для управления `SameSite` и `Secure` флагами кук (легитимно)
+- `errors.go` — для скрытия деталей ошибок в production (легитимно через глобальную переменную `AppEnv`)
+- Для всех остальных — либо через глобальный `AppEnv`, либо не нужен
+
+## Связанные заметки
+
+- [[architecture-overview]]
+- [[MOC-backend-patterns]]
+
+#golang #antipattern #yagni #code-review

+ 45 - 0
obsidian_data/Photoplaces_data/decision-validation-error-production.md

@@ -0,0 +1,45 @@
+# Decision Record: Скрытие деталей валидации в production
+
+**Дата:** 2026-06-24
+**Статус:** Implemented
+
+## Контекст
+
+`writeValidationError` возвращал `err.Error()` в теле ответа всегда, включая production. Это утекало:
+
+- Имена полей: `Key: 'createBookingRequest.EndTime'`
+- Типы правил: `Error:Field validation for 'EndTime' failed on the 'datetime' tag`
+- Внутреннюю структуру API
+
+## Решение
+
+В production: `details: "validation failed"`
+В development: `details: err.Error()` (полная картина для отладки)
+
+```go
+func writeValidationError(w http.ResponseWriter, err error) {
+    details := err.Error()
+    if AppEnv == "production" {
+        details = "validation failed"
+    }
+    writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
+        "error":   "validation failed",
+        "details": details,
+    })
+}
+```
+
+## Альтернативы
+
+| Вариант | Плюсы | Минусы |
+|---------|-------|--------|
+| Возвращать translated messages | Понятно клиенту | Нужен словарь переводов |
+| Коды ошибок вместо текста | Абстракция | Клиент всё равно должен маппить |
+| Отключить валидацию в production?! | — | Глупость |
+
+## Связанные заметки
+
+- [[backend-validation]]
+- [[atomic-csp-configuration]]
+
+#decision-record #security #validation #api