Browse Source

fix: rate limiter, ws broadcast, upload url, owner checks, docs

neyrogovnarik 2 months ago
parent
commit
b22a56b358

+ 1 - 0
backend/.env.example

@@ -3,6 +3,7 @@ DATABASE_URL=postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?s
 REDIS_URL=redis://localhost:6379/0
 
 S3_ENDPOINT=http://localhost:9000
+S3_PUBLIC_ENDPOINT=http://localhost:9000
 S3_ACCESS_KEY=photoplaces
 S3_SECRET_KEY=photoplaces_dev
 S3_BUCKET=photoplaces

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

@@ -46,7 +46,7 @@ func main() {
 	bookingHandler := handlers.NewBookingHandler(bookingRepo, placeRepo)
 	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
 	wsHub := handlers.NewWSHub()
-	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
+	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
 	if err != nil {
 		log.Fatalf("upload handler: %v", err)
 	}

+ 61 - 9
backend/docs/API_CONTRACT.md

@@ -7,7 +7,7 @@
 
 ## Аутентификация
 - **Access Token**: JWT, срок 15 мин, в заголовке `Authorization: Bearer <token>`
-- **Refresh Token**: HTTP-only cookie, срок 30 дней, ротация при использовании
+- **Refresh Token**: JWT, срок 30 дней, передаётся в заголовке `X-Refresh-Token`
 - **Роли**: `superadmin`, `moderator`, `landlord`, `executor`, `customer`
 
 ## Формат ошибок (RFC 7807)
@@ -34,14 +34,15 @@
 {
   "email": "user@example.com",
   "password": "securePass123",
-  "role": "customer"  // customer | landlord | executor
+  "role": "customer",  // customer | landlord | executor
+  "name": "Иван Иванов"
 }
 
 // Ответ 201
 {
   "user": { "id": "uuid", "email": "...", "role": "customer", "created_at": "..." },
   "access_token": "eyJ...",
-  "refresh_token": "eyJ..."  // также устанавливается HttpOnly cookie
+  "refresh_token": "eyJ..."
 }
 ```
 
@@ -62,11 +63,11 @@
 ```
 
 ### POST /auth/refresh
-- На основе cookie, тело не требуется
+- Заголовок: `X-Refresh-Token: <refresh_token>`
 - Ответ 200: новый access_token + обновлённый refresh_token
 
 ### POST /auth/logout
-- Инвалидирует refresh token
+- Заголовок: `Authorization: Bearer <access_token>`
 - Ответ 204
 
 ### GET /auth/me
@@ -174,8 +175,9 @@
 - Редактирование владельцем переводит в статус `pending_moderation`
 
 ### DELETE /places/:id
-**Роли:** Владелец, `moderator`, `superadmin`
-- Мягкое удаление
+**Роли:** Владелец (свои места), `moderator`, `superadmin`
+- Проверка: `owner_id` == текущий пользователь || moderator || superadmin
+- Мягкое удаление (`deleted_at`)
 
 ### POST /places/:id/moderate
 **Роли:** `moderator`, `superadmin`
@@ -210,10 +212,12 @@
 ```
 
 ### PATCH /services/:id
-**Роль:** Владелец, `moderator`, `superadmin`
+**Роль:** Владелец (свои услуги), `moderator`, `superadmin`
+- Проверка: `executor_id` == текущий пользователь || moderator || superadmin
 
 ### DELETE /services/:id
-**Роль:** Владелец, `moderator`, `superadmin`
+**Роль:** Владелец (свои услуги), `moderator`, `superadmin`
+- Проверка: `executor_id` == текущий пользователь || moderator || superadmin
 
 ---
 
@@ -224,6 +228,15 @@
 
 ### PATCH /users/me
 **Обновление своего профиля**
+```json
+{
+  "name": "Новое имя",
+  "phone": "+79991234567",
+  "bio": "Фотограф с 10-летним стажем",
+  "avatar_url": "https://cdn.../avatar.jpg",
+  "country": "RU"
+}
+```
 
 ### GET /users/me/places
 **Свои места (все статусы)**
@@ -258,6 +271,45 @@
 
 ---
 
+## Бронирования (Bookings)
+
+### POST /bookings
+**Роль:** Любой авторизованный пользователь
+```json
+{
+  "place_id": "uuid",
+  "start_time": "2024-02-01T10:00:00Z",
+  "end_time": "2024-02-01T14:00:00Z",
+  "comment": "Нужна циклорама"
+}
+```
+- Проверка доступности слота
+- Расчёт цены на основе `hourly_rate`
+
+### GET /bookings/me
+**Список своих бронирований**
+
+### PATCH /bookings/:id/cancel
+**Роль:** Владелец брони
+- Проверка: `user_id` == текущий пользователь
+
+### PATCH /bookings/:id/confirm
+**Роль:** `moderator`, `superadmin`
+
+---
+
+## WebSocket — точки посетителей
+
+### GET /ws/visitors
+- WebSocket-соединение для получения точек других посетителей на карте
+- Клиент отправляет `{ "user_id": "...", "lat": 55.75, "lng": 37.61 }`
+- Сервер рассылает всем подключённым клиентам список всех активных точек:
+```json
+{ "visitors": [{ "user_id": "...", "lat": 55.75, "lng": 37.61 }] }
+```
+
+---
+
 ## Теги и характеристики (справочные данные)
 
 ### GET /tags

+ 8 - 3
backend/docs/SPEC.md

@@ -106,12 +106,14 @@
 
 ## Безопасность
 
-- Ограничение запросов (Redis)
+- Ограничение запросов (in-memory rate limiter с очисткой старых записей)
 - CORS, Helmet/CSP заголовки
 - Валидация ввода (go-playground/validator)
-- Параметризованные SQL-запросы
+- Параметризованные SQL-запросы (pgx)
 - Валидация загружаемых файлов (тип, размер)
 - Мягкое удаление (`deleted_at`)
+- Проверка владельца ресурса: изменение/удаление места, услуги или брони доступно только владельцу, модератору или суперадминистратору
+- JWT-аутентификация: access token (15 мин) + refresh token (30 дней, X-Refresh-Token заголовок)
 
 ## Окружения
 
@@ -122,6 +124,9 @@
 ## Планы на будущее
 
 - Мобильные приложения (React Native / Expo)
-- WebSocket для точек посетителей в реальном времени
 - ML-рекомендации мест
 - Партнёрская программа для фотографов
+- Redis-кеширование для карты и списков
+- Геопоиск по радиусу с использованием PostGIS
+- Модерация услуг исполнителей
+- Система уведомлений (email/push)

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

@@ -14,10 +14,11 @@ type Config struct {
 	DatabaseURL string
 	RedisURL    string
 
-	S3Endpoint  string
-	S3AccessKey string
-	S3SecretKey string
-	S3Bucket    string
+	S3Endpoint       string
+	S3PublicEndpoint string
+	S3AccessKey      string
+	S3SecretKey      string
+	S3Bucket         string
 
 	JWTSecret        string
 	JWTRefreshSecret string
@@ -38,8 +39,9 @@ func Load() *Config {
 		DatabaseURL: getEnv("DATABASE_URL", "postgres://photoplaces:photoplaces_dev@localhost:5432/photoplaces?sslmode=disable"),
 		RedisURL:    getEnv("REDIS_URL", "redis://localhost:6379/0"),
 
-		S3Endpoint:  getEnv("S3_ENDPOINT", "http://localhost:9000"),
-		S3AccessKey: getEnv("S3_ACCESS_KEY", "photoplaces"),
+		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"),
 

+ 18 - 2
backend/internal/handlers/places.go

@@ -204,8 +204,8 @@ func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) {
 	if req.Lng != 0 { input.Lng = &req.Lng }
 	input.AccessInfo = req.AccessInfo
 	input.HourlyRate = req.HourlyRate
-	input.Currency = &req.Currency
-	input.MinHours = &req.MinHours
+	if req.Currency != "" { input.Currency = &req.Currency }
+	if req.MinHours != 0 { input.MinHours = &req.MinHours }
 
 	place, err := h.placeSvc.Update(r.Context(), input, isModerator)
 	if err != nil {
@@ -243,6 +243,22 @@ func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) {
 
 func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+	role := middleware.GetUserRole(r.Context())
+
+	place, err := h.placeSvc.GetByID(r.Context(), id, false)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if place == nil {
+		writeError(w, http.StatusNotFound, "place not found")
+		return
+	}
+	if place.OwnerID != userID && role != "moderator" && role != "superadmin" {
+		writeError(w, http.StatusForbidden, "not your place")
+		return
+	}
 
 	if err := h.placeSvc.Delete(r.Context(), id); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())

+ 29 - 5
backend/internal/handlers/services.go

@@ -131,6 +131,8 @@ func (h *ServiceHandler) Create(w http.ResponseWriter, r *http.Request) {
 
 func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+	role := middleware.GetUserRole(r.Context())
 
 	var req createServiceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -148,11 +150,16 @@ func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	svc.Title = req.Title
-	svc.Description = req.Description
-	svc.Price = req.Price
-	svc.Currency = req.Currency
-	svc.DurationMinutes = req.DurationMinutes
+	if svc.ExecutorID != userID && role != "moderator" && role != "superadmin" {
+		writeError(w, http.StatusForbidden, "not your service")
+		return
+	}
+
+	if req.Title != "" { svc.Title = req.Title }
+	if req.Description != nil { svc.Description = req.Description }
+	if req.Price > 0 { svc.Price = req.Price }
+	if req.Currency != "" { svc.Currency = req.Currency }
+	if req.DurationMinutes != nil { svc.DurationMinutes = req.DurationMinutes }
 
 	if err := h.serviceRepo.Update(r.Context(), svc); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
@@ -164,6 +171,23 @@ func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 
 func (h *ServiceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
+	userID := middleware.GetUserID(r.Context())
+	role := middleware.GetUserRole(r.Context())
+
+	svc, err := h.serviceRepo.GetByID(r.Context(), id)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	if svc == nil {
+		writeError(w, http.StatusNotFound, "service not found")
+		return
+	}
+	if svc.ExecutorID != userID && role != "moderator" && role != "superadmin" {
+		writeError(w, http.StatusForbidden, "not your service")
+		return
+	}
+
 	if err := h.serviceRepo.SoftDelete(r.Context(), id); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return

+ 10 - 8
backend/internal/handlers/upload.go

@@ -11,12 +11,13 @@ import (
 )
 
 type UploadHandler struct {
-	minioClient *minio.Client
-	bucket      string
-	endpoint    string
+	minioClient    *minio.Client
+	bucket         string
+	endpoint       string
+	publicEndpoint string
 }
 
-func NewUploadHandler(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*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,
@@ -26,9 +27,10 @@ func NewUploadHandler(endpoint, accessKey, secretKey, bucket string, useSSL bool
 	}
 
 	return &UploadHandler{
-		minioClient: client,
-		bucket:      bucket,
-		endpoint:    endpoint,
+		minioClient:    client,
+		bucket:         bucket,
+		endpoint:       endpoint,
+		publicEndpoint: publicEndpoint,
 	}, nil
 }
 
@@ -87,7 +89,7 @@ func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	fileURL := "http://" + h.endpoint + "/" + h.bucket + "/" + objectName
+	fileURL := h.publicEndpoint + "/" + h.bucket + "/" + objectName
 
 	writeJSON(w, http.StatusOK, presignedURLResponse{
 		UploadURL: url.String(),

+ 6 - 3
backend/internal/handlers/websocket.go

@@ -59,10 +59,13 @@ func (h *WSHub) HandleWS(w http.ResponseWriter, r *http.Request) {
 		for _, v := range h.clients {
 			visitors = append(visitors, v)
 		}
-		h.mu.Unlock()
 
-		if err := conn.WriteJSON(map[string]interface{}{"visitors": visitors}); err != nil {
-			break
+		for c := range h.clients {
+			if err := c.WriteJSON(map[string]interface{}{"visitors": visitors}); err != nil {
+				c.Close()
+				delete(h.clients, c)
+			}
 		}
+		h.mu.Unlock()
 	}
 }

+ 1 - 6
backend/internal/middleware/ratelimit.go

@@ -42,12 +42,6 @@ func (rl *RateLimiter) cleanup() {
 }
 
 func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
-	rl := &RateLimiter{
-		visitors: make(map[string]*rate.Limiter),
-		rate:     60,
-		burst:    60,
-	}
-
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			ip := r.RemoteAddr
@@ -57,6 +51,7 @@ func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
 				limiter = rate.NewLimiter(rl.rate, rl.burst)
 				rl.visitors[ip] = limiter
 			}
+			rl.lastSeen[ip] = time.Now()
 			rl.mu.Unlock()
 
 			if !limiter.Allow() {

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

@@ -113,6 +113,10 @@ func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isMod
 		return nil, nil
 	}
 
+	if !isModerator && place.OwnerID != input.OwnerID {
+		return nil, fmt.Errorf("not your place")
+	}
+
 	if input.Title != nil { place.Title = *input.Title }
 	if input.Description != nil { place.Description = input.Description }
 	if input.Address != nil { place.Address = input.Address }

+ 1 - 0
deploy/docker-compose.prod.yml

@@ -69,6 +69,7 @@ services:
       DATABASE_URL: postgres://photoplaces:${DB_PASSWORD}@postgres:5432/photoplaces?sslmode=disable
       REDIS_URL: redis://:${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_BUCKET: photoplaces

+ 2 - 0
deploy/env.prod

@@ -9,6 +9,8 @@ JWT_SECRET=CHANGE_ME_JWT_SECRET_32_HEX
 JWT_REFRESH_SECRET=CHANGE_ME_REFRESH_SECRET_32_HEX
 
 # S3 / MinIO
+S3_ENDPOINT=http://minio:9000
+S3_PUBLIC_ENDPOINT=http://192.168.88.128:9000
 S3_ACCESS_KEY=photoplaces
 S3_SECRET_KEY=CHANGE_ME_MINIO_PASSWORD