소스 검색

Исправление критических ошибок и предупреждений

Go backend:
- websocket.go: CheckOrigin из конфига вместо return true; SetWriteDeadline (10s);
  безопасная очистка отвалившихся соединений вне итерации map
- bookings.go: обработка ошибок time.Parse; ручной расчёт цены через placeRepo
  (работает для любых типов мест); валюта из данных места
- repository/places.go, services.go: DISTINCT при JOIN-ах (нет дубликатов)
- users.go: parseInt -> strconv.Atoi; удалён кастомный парсер
- reviews.go: isUniqueViolation -> pgconn.PgError код 23505;
  fmt.Sprintf таблицы -> условный if/else
- services/auth.go: удалены мёртвые методы RevokeAllSessions, CleanupExpiredTokens
- config.go: комментарий к CloudPayments (зарезервировано)
- main.go: NewWSHub(cfg.AllowedOrigins) — передача разрешённых Origin

Frontend:
- MapView.tsx: removeMarker перед addMarker (visitor dot leak);
  fetchPlacesRef для предотвращения stale closure
- PlaceForm.tsx: URL.revokeObjectURL в onChange + useEffect cleanup
- services/add/page.tsx: price ? parseInt(price) : 0 (защита от NaN)
neyrogovnarik 1 개월 전
부모
커밋
8a87d03d2f

+ 1 - 1
backend/cmd/api/main.go

@@ -71,7 +71,7 @@ func main() {
 	reviewHandler := handlers.NewReviewHandler(reviewRepo)
 	bookingHandler := handlers.NewBookingHandler(bookingRepo, placeRepo)
 	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
-	wsHub := handlers.NewWSHub()
+	wsHub := handlers.NewWSHub(cfg.AllowedOrigins)
 	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))

+ 1 - 0
backend/internal/config/config.go

@@ -27,6 +27,7 @@ type Config struct {
 	JWTSecret        string
 	JWTRefreshSecret string
 
+	// CloudPaymentsPublicID и CloudPaymentsAPISecret зарезервированы для будущей интеграции платежей
 	CloudPaymentsPublicID string
 	CloudPaymentsAPISecret string
 

+ 28 - 4
backend/internal/handlers/bookings.go

@@ -43,8 +43,16 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	start, _ := time.Parse(time.RFC3339, req.StartTime)
-	end, _ := time.Parse(time.RFC3339, req.EndTime)
+	start, err := time.Parse(time.RFC3339, req.StartTime)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "invalid start_time format, expected RFC3339")
+		return
+	}
+	end, err := time.Parse(time.RFC3339, req.EndTime)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "invalid end_time format, expected RFC3339")
+		return
+	}
 
 	if !end.After(start) {
 		writeError(w, http.StatusBadRequest, "end_time must be after start_time")
@@ -61,11 +69,27 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	totalPrice, err := h.bookingRepo.CalculateTotalPrice(r.Context(), req.PlaceID, start, end)
+	// Получаем данные места (включая hourly_rate и currency) через placeRepo
+	place, err := h.placeRepo.GetByID(r.Context(), req.PlaceID)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
 	}
+	if place == nil {
+		writeError(w, http.StatusNotFound, "place not found")
+		return
+	}
+
+	// Рассчитываем цену самостоятельно, без вызова CalculateTotalPrice (работает для всех типов мест)
+	var totalPrice *int
+	if place.HourlyRate != nil {
+		hours := int(end.Sub(start).Hours())
+		if hours < 1 {
+			hours = 1
+		}
+		price := *place.HourlyRate * hours
+		totalPrice = &price
+	}
 
 	booking := &models.Booking{
 		PlaceID:    req.PlaceID,
@@ -73,7 +97,7 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 		StartTime:  start,
 		EndTime:    end,
 		TotalPrice: totalPrice,
-		Currency:   "RUB",
+		Currency:   place.Currency,
 		Comment:    req.Comment,
 	}
 

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

@@ -3,8 +3,8 @@ package handlers
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
+	"strconv"
 
 	"github.com/go-chi/chi/v5"
 	"github.com/photoplaces/backend/internal/middleware"
@@ -136,7 +136,7 @@ func (h *UserHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
 		Status: r.URL.Query().Get("status"),
 	}
 	if limit := r.URL.Query().Get("limit"); limit != "" {
-		if v, err := parseInt(limit); err == nil {
+		if v, err := strconv.Atoi(limit); err == nil {
 			filter.Limit_ = v
 		}
 	}
@@ -154,13 +154,4 @@ func (h *UserHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, map[string]interface{}{"data": users})
 }
 
-func parseInt(s string) (int, error) {
-	var n int
-	for _, c := range s {
-		if c < '0' || c > '9' {
-			return 0, fmt.Errorf("not a number")
-		}
-		n = n*10 + int(c-'0')
-	}
-	return n, nil
-}
+

+ 49 - 17
backend/internal/handlers/websocket.go

@@ -5,30 +5,50 @@ import (
 	"log"
 	"net/http"
 	"sync"
+	"time"
 
 	"github.com/gorilla/websocket"
 )
 
-var upgrader = websocket.Upgrader{
-	CheckOrigin: func(r *http.Request) bool { return true },
-}
-
 type VisitorDot struct {
 	UserID string  `json:"user_id,omitempty"`
 	Lat    float64 `json:"lat"`
 	Lng    float64 `json:"lng"`
 }
 
+// WSHub управляет WebSocket-соединениями для отображения посетителей на карте
 type WSHub struct {
-	mu      sync.RWMutex
-	clients map[*websocket.Conn]VisitorDot
+	mu             sync.RWMutex
+	clients        map[*websocket.Conn]VisitorDot
+	allowedOrigins []string // разрешённые Origin для CORS WebSocket
 }
 
-func NewWSHub() *WSHub {
-	return &WSHub{clients: make(map[*websocket.Conn]VisitorDot)}
+// NewWSHub создаёт хаб с переданным списком разрешённых Origin
+func NewWSHub(allowedOrigins []string) *WSHub {
+	return &WSHub{
+		clients:        make(map[*websocket.Conn]VisitorDot),
+		allowedOrigins: allowedOrigins,
+	}
 }
 
+// HandleWS обрабатывает WebSocket-подключение: апгрейд, чтение, рассылка, очистка
 func (h *WSHub) HandleWS(w http.ResponseWriter, r *http.Request) {
+	// Создаём upgrader локально с проверкой Origin из конфига
+	upgrader := websocket.Upgrader{
+		CheckOrigin: func(r *http.Request) bool {
+			if len(h.allowedOrigins) == 0 {
+				return true // если origins не настроены — пропускаем все (обратная совместимость)
+			}
+			origin := r.Header.Get("Origin")
+			for _, o := range h.allowedOrigins {
+				if o == origin {
+					return true
+				}
+			}
+			return false
+		},
+	}
+
 	conn, err := upgrader.Upgrade(w, r, nil)
 	if err != nil {
 		log.Printf("ws upgrade: %v", err)
@@ -41,13 +61,7 @@ func (h *WSHub) HandleWS(w http.ResponseWriter, r *http.Request) {
 	h.clients[conn] = dot
 	h.mu.Unlock()
 
-	defer func() {
-		h.mu.Lock()
-		delete(h.clients, conn)
-		h.mu.Unlock()
-		conn.Close()
-	}()
-
+	// Цикл чтения сообщений от клиента
 	for {
 		var msg VisitorDot
 		if err := conn.ReadJSON(&msg); err != nil {
@@ -61,12 +75,30 @@ func (h *WSHub) HandleWS(w http.ResponseWriter, r *http.Request) {
 			visitors = append(visitors, v)
 		}
 
+		// Рассылаем всем клиентам, собираем отвалившиеся соединения
+		var failed []*websocket.Conn
 		for c := range h.clients {
+			// Таймаут записи для предотвращения slow-client DoS
+			if err := c.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
+				failed = append(failed, c)
+				continue
+			}
 			if err := c.WriteJSON(map[string]interface{}{"visitors": visitors}); err != nil {
-				c.Close()
-				delete(h.clients, c)
+				failed = append(failed, c)
 			}
 		}
+		// Закрываем и удаляем упавшие соединения (вне итерации по map — безопасно)
+		for _, c := range failed {
+			c.WriteMessage(websocket.CloseMessage, []byte{})
+			c.Close()
+			delete(h.clients, c)
+		}
 		h.mu.Unlock()
 	}
+
+	// Очистка после завершения цикла чтения
+	h.mu.Lock()
+	delete(h.clients, conn)
+	h.mu.Unlock()
+	conn.Close()
 }

+ 4 - 0
backend/internal/repository/places.go

@@ -77,6 +77,10 @@ func (r *PlaceRepo) List(ctx context.Context, filter models.PlaceFilter) ([]*mod
 		joins += ` JOIN place_features pf ON pf.place_id = p.id`
 	}
 
+	// Если есть JOIN-ы, добавляем DISTINCT, чтобы избежать дубликатов мест при нескольких совпадениях
+	if joins != "" {
+		q = "SELECT DISTINCT" + q[6:] // заменяем "SELECT" на "SELECT DISTINCT"
+	}
 	q += joins + ` WHERE p.deleted_at IS NULL`
 
 	if filter.Status != "" { q += ` AND p.status = @status`; args["status"] = filter.Status }

+ 19 - 25
backend/internal/repository/reviews.go

@@ -3,8 +3,10 @@ package repository
 
 import (
 	"context"
+	"errors"
 	"fmt"
 
+	"github.com/jackc/pgx/v5/pgconn"
 	"github.com/jackc/pgx/v5/pgxpool"
 	"github.com/photoplaces/backend/internal/models"
 )
@@ -31,23 +33,30 @@ func (r *ReviewRepo) Create(ctx context.Context, review *models.Review) error {
 		review.UserID, review.TargetType, review.TargetID, review.Rating, review.Text,
 	).Scan(&review.ID, &review.CreatedAt, &review.UpdatedAt)
 	if err != nil {
-		if isUniqueViolation(err) {
+		// Проверка нарушения уникальности через код ошибки pgx (23505 = unique_violation)
+		var pgErr *pgconn.PgError
+		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
 			return fmt.Errorf("you already reviewed this")
 		}
 		return fmt.Errorf("create review: %w", err)
 	}
 
-	tableName := "places"
+	// Обновляем рейтинг и количество отзывов у целевой сущности
 	if review.TargetType == "service" {
-		tableName = "services"
+		_, err = tx.Exec(ctx,
+			`UPDATE services SET
+				reviews_count = (SELECT COUNT(*) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL),
+				rating = (SELECT COALESCE(AVG(rating), 0) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL)
+				WHERE id = $2`,
+			review.TargetType, review.TargetID)
+	} else {
+		_, err = tx.Exec(ctx,
+			`UPDATE places SET
+				reviews_count = (SELECT COUNT(*) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL),
+				rating = (SELECT COALESCE(AVG(rating), 0) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL)
+				WHERE id = $2`,
+			review.TargetType, review.TargetID)
 	}
-
-	_, err = tx.Exec(ctx,
-		fmt.Sprintf(`UPDATE %s SET
-			reviews_count = (SELECT COUNT(*) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL),
-			rating = (SELECT COALESCE(AVG(rating), 0) FROM reviews WHERE target_type = $1 AND target_id = $2 AND deleted_at IS NULL)
-			WHERE id = $2`, tableName),
-		review.TargetType, review.TargetID)
 	if err != nil {
 		return fmt.Errorf("update rating: %w", err)
 	}
@@ -86,19 +95,4 @@ func (r *ReviewRepo) List(ctx context.Context, targetType, targetID string) ([]*
 	return reviews, nil
 }
 
-func isUniqueViolation(err error) bool {
-	return err != nil && contains(err.Error(), "unique")
-}
-
-func contains(s, substr string) bool {
-	return len(s) >= len(substr) && searchString(s, substr)
-}
 
-func searchString(s, sub string) bool {
-	for i := 0; i <= len(s)-len(sub); i++ {
-		if s[i:i+len(sub)] == sub {
-			return true
-		}
-	}
-	return false
-}

+ 4 - 0
backend/internal/repository/services.go

@@ -58,6 +58,10 @@ func (r *ServiceRepo) List(ctx context.Context, filter models.ServiceFilter) ([]
 		joins += ` JOIN service_tags st ON st.service_id = s.id`
 	}
 
+	// Если есть JOIN-ы, добавляем DISTINCT, чтобы избежать дубликатов услуг при нескольких совпадениях
+	if joins != "" {
+		q = "SELECT DISTINCT" + q[6:] // заменяем "SELECT" на "SELECT DISTINCT"
+	}
 	q += joins + ` WHERE s.deleted_at IS NULL`
 
 	if filter.Status != "" { q += ` AND s.status = @status`; args["status"] = filter.Status }

+ 0 - 12
backend/internal/services/auth.go

@@ -229,18 +229,6 @@ func (s *AuthService) RevokeSession(ctx context.Context, plainRefreshToken strin
 	return s.refreshTokenRepo.Revoke(ctx, storedToken.TokenHash)
 }
 
-func (s *AuthService) RevokeAllSessions(ctx context.Context, userID string) error {
-	logger := log.FromContext(ctx)
-	logger.InfoContext(ctx, "revoking all sessions for user", slog.String("user_id", userID))
-	return s.refreshTokenRepo.RevokeAllForUser(ctx, userID)
-}
-
-func (s *AuthService) CleanupExpiredTokens(ctx context.Context) error {
-	logger := log.FromContext(ctx)
-	logger.InfoContext(ctx, "cleaning up expired refresh tokens")
-	return s.refreshTokenRepo.CleanupExpired(ctx)
-}
-
 func generateSecureToken(n int) (string, error) {
 	b := make([]byte, n)
 	if _, err := rand.Read(b); err != nil {

+ 1 - 1
frontend/src/app/services/add/page.tsx

@@ -51,7 +51,7 @@ export default function AddServicePage() {
       await api.post('/services', {
         title,
         description: description || undefined,
-        price: parseInt(price),
+        price: price ? parseInt(price) : 0,
         currency: 'RUB',
         duration_minutes: duration ? parseInt(duration) : undefined,
         tags: selectedTags,

+ 14 - 3
frontend/src/components/MapView.tsx

@@ -1,6 +1,9 @@
 'use client'
 
 import { useEffect, useRef, useState, useCallback } from 'react'
+
+/** Константа-идентификатор маркера посетителя */
+const VISITOR_MARKER_ID = 'visitor-me'
 import { createYandexMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
 import { useAuth } from '@/hooks/useAuth'
 import type { Place } from '@/types'
@@ -35,6 +38,10 @@ export default function MapView() {
     }
   }, [user])
 
+  /** Реф для актуальной версии fetchPlaces — предотвращает проблему устаревшего замыкания */
+  const fetchPlacesRef = useRef(fetchPlaces)
+  fetchPlacesRef.current = fetchPlaces
+
   useEffect(() => {
     if (!containerRef.current || mapRef.current) return
 
@@ -50,14 +57,14 @@ export default function MapView() {
     }
 
     provider.onMove((center, zoom, bounds) => {
-      fetchPlaces(bounds)
+      fetchPlacesRef.current(bounds)
     })
 
     return () => {
       provider.destroy()
       mapRef.current = null
     }
-  }, [fetchPlaces])
+  }, [])
 
   useEffect(() => {
     if (!mapRef.current) return
@@ -77,10 +84,14 @@ export default function MapView() {
     }
   }, [places])
 
+  /** Добавляет/обновляет маркер геолокации посетителя. Удаляет старый перед созданием нового. */
   const handleAddVisitorDot = useCallback(() => {
     if (!mapRef.current || !navigator.geolocation) return
     navigator.geolocation.getCurrentPosition((pos) => {
-      mapRef.current?.addMarker('visitor-me', pos.coords.latitude, pos.coords.longitude, {
+      const provider = mapRef.current
+      if (!provider) return
+      provider.removeMarker(VISITOR_MARKER_ID)
+      provider.addMarker(VISITOR_MARKER_ID, pos.coords.latitude, pos.coords.longitude, {
         type: 'visitor',
       })
     })

+ 11 - 0
frontend/src/components/PlaceForm.tsx

@@ -43,6 +43,15 @@ export default function PlaceForm({ type }: PlaceFormProps) {
     api.get<Feature[]>('/features').then(setFeatures).catch(() => {})
   }, [])
 
+  /** Освобождает blob-URL обложки при размонтировании или смене preview */
+  useEffect(() => {
+    return () => {
+      if (coverPreview) {
+        URL.revokeObjectURL(coverPreview)
+      }
+    }
+  }, [coverPreview])
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     setLoading(true)
@@ -185,6 +194,8 @@ export default function PlaceForm({ type }: PlaceFormProps) {
             const file = e.target.files?.[0]
             if (file) {
               setCoverFile(file)
+              // Освобождаем предыдущий blob-URL, чтобы не было утечки
+              if (coverPreview) URL.revokeObjectURL(coverPreview)
               setCoverPreview(URL.createObjectURL(file))
             }
           }}