Kaynağa Gözat

fix: code audit 2026-06-30 — 14 багов исправлено

- Бэкенд: defer Unlock, безопасный RoleMiddleware, запрет эскалации superadmin
- Бэкенд: TOCTOU race регистрации (INSERT без предпроверки)
- Бэкенд: default status='active' для новых пользователей
- Бэкенд: проверка статуса published при бронировании
- Бэкенд: лог ошибки bounds, DELETE только query, убран node_modules volume
- Фронт: /auth/logout в noRefreshPaths, catch {} → console.warn
- Фронт: ErrorBoundary логирует в production, ошибка карты пользователю
- Фронт: next.config.js remotePattern для S3-прокси
- Доки: README, API_CONTRACT, Obsidian notes актуализированы
- Удалены остатки Yandex Maps
neyrogovnarik 1 ay önce
ebeveyn
işleme
c38d225bb0

+ 3 - 1
.opencode/skills/photoplaces-senior/SKILL.md

@@ -37,7 +37,9 @@ description: >
 5. Если видишь потенциальную проблему в ТЗ — предупреди вежливо, но конструктивно.
 6. Общение со мной и документация и комментарии в коде строго на русском языке.
 7. Делай push в репозиторий ветка main: http://192.168.88.96:10880/foxtime/photoplaces.git
-8. Делай деплой на тестовый сервер, доступ по ssh foxtime@192.168.88.129
+8. Делай деплой на тестовый сервер, доступ по ssh foxtime@192.168.88.128
+9. Всегда актуализируй документацию и заметки.
+10. Пиши тестируемый код.
 
 ## Obsidian — второй мозг
 

+ 24 - 5
README.md

@@ -166,27 +166,41 @@ make migrate  # Запуск миграций
 | GET | /auth/me | + |
 | GET | /places | Нет |
 | GET | /places/:id | Нет |
+| GET | /places/my | + |
 | POST | /places | + |
 | PATCH | /places/:id | + |
 | DELETE | /places/:id | + |
 | POST | /places/:id/moderate | moderator+ |
+| POST | /places/:id/restore | moderator+ |
 | GET | /tags | Нет |
 | GET | /features | Нет |
 | GET | /services | Нет |
+| GET | /services/:id | Нет |
 | POST | /services | + |
 | PATCH | /services/:id | + |
 | DELETE | /services/:id | + |
 | POST | /bookings | + |
 | GET | /bookings/me | + |
 | PATCH | /bookings/:id/cancel | + |
+| PATCH | /bookings/:id/confirm | moderator+ |
 | GET | /reviews | Нет |
 | POST | /reviews | + |
 | GET | /users/:id | Нет |
 | PATCH | /users/me | + |
 | GET | /admin/users | moderator+ |
+| POST | /admin/users | moderator+ |
 | PATCH | /admin/users/:id | moderator+ |
+| POST | /admin/tags | moderator+ |
+| DELETE | /admin/tags | moderator+ |
+| POST | /admin/features | moderator+ |
+| DELETE | /admin/features | moderator+ |
+| DELETE | /admin/places/:id | moderator+ (hard delete) |
+| POST | /upload | + |
 | POST | /upload/presigned-url | + |
 | GET | /ws/visitors | Нет (WebSocket) |
+| GET | /admin/setup-status | Нет |
+| POST | /admin/setup | Нет |
+| GET | /health | Нет |
 
 ## Конфигурация
 
@@ -212,18 +226,21 @@ make migrate  # Запуск миграций
 | Переменная | По умолчанию |
 |-----------|-------------|
 | NEXT_PUBLIC_API_URL | http://localhost:8080/api/v1 |
-| NEXT_PUBLIC_MAP_PROVIDER | yandex |
-| NEXT_PUBLIC_YANDEX_MAPS_API_KEY | (задаётся) |
+| NEXT_PUBLIC_MAP_PROVIDER | leaflet |
 
 ## Миграции
 
-Миграции запускаются автоматически через `docker-entrypoint.sh` при старте бэкенда, либо отдельно:
+Миграции запускаются автоматически через `backend/docker-entrypoint.sh` при старте бэкенда.
+
+Отдельно (локально):
 
 ```bash
+make migrate
+# или
 docker compose run --rm migrate
 ```
 
-Файлы миграций: `backend/migrations/`
+Файлы миграций: `backend/migrations/` (11 миграций)
 
 ## Роли пользователей
 
@@ -243,5 +260,7 @@ docker compose run --rm migrate
 - Защита от SQL-инъекций: pgx с параметризованными запросами
 - Валидация ввода: go-playground/validator
 - Мягкое удаление (soft delete: deleted_at)
-- Content Security Policy (CSP) через Caddy
+- Content Security Policy (CSP) через Caddy (в production)
 - WebSocket origin check: только разрешённые домены
+- Проверка сигнатуры файлов (magic bytes) при загрузке изображений
+- Rate limiter: Redis (sliding window) + in-memory fallback с fail-open/closed

+ 9 - 7
backend/docs/API_CONTRACT.md

@@ -2,8 +2,7 @@
 
 ## Базовый URL
 - Локально: `http://localhost:8080/api/v1`
-- Стенд: `https://staging.api.photoplaces.ru/api/v1`
-- Прод: `https://api.photoplaces.ru/api/v1`
+- Тестовый сервер: `http://192.168.88.128/api/v1`
 
 ## Аутентификация
 - **Access Token**: JWT, срок 15 мин, в заголовке `Authorization: Bearer <token>`
@@ -84,7 +83,7 @@ HTTP статусы: 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404
 - `limit`: 1-100 (по умолчанию 20)
 - `sort`: `rating` | `created_at` | `distance` (требует `user_lat,user_lng`)
 
-**Ответ 200:**
+**Ответ 200 (cursor-based pagination):**
 ```json
 {
   "data": [
@@ -109,7 +108,9 @@ HTTP статусы: 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404
       "features": [{ "id": "parking", "name": "Парковка", "sort_order": 1 }],
       "created_at": "2024-01-15T10:30:00Z"
     }
-  ]
+  ],
+  "next_cursor": "base64encodedcursorstring",
+  "has_more": true
 }
 ```
 
@@ -364,9 +365,10 @@ HTTP статусы: 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404
 ---
 
 ## Лимиты запросов
-- Авторизация: 10 запросов/мин на IP
-- Чтение мест: 60 запросов/мин на пользователя
-- Запись мест: 10 запросов/мин на пользователя
+- Авторизация: 30 запросов/мин на IP
+- Чтение: 60 запросов/мин на пользователя
+- Запись: 10 запросов/мин на пользователя
 - Администрирование: 100 запросов/мин
+- Redis + in-memory fallback, настраивается fail-open/fail-closed
 
 ---

+ 10 - 84
backend/docs/MAP_PROVIDERS.md

@@ -1,90 +1,16 @@
-# Провайдеры онлайн-карт для РФ
+# Провайдер карт
 
-## Критерии выбора
-- Работоспособность в России (не заблокированы)
-- Схематический стиль (не спутник)
-- JavaScript API для встраивания в веб
-- Бесплатный тариф для старта
-- Производительность и кастомизация маркеров
-
----
-
-## Варианты
-
-### 1. Яндекс Карты (Яндекс JS API v3)
-
-| Характеристика | Значение |
-|----------------|----------|
-| Статус в РФ | ✅ Работает |
-| Схематический стиль | ✅ `yandex#map` + кастомные стили |
-| Бесплатный тариф | ✅ 10 000 запросов/сутки, дольше 30 дней — от 300р/мес |
-| Гео-поиск | ✅ Встроенный поиск по адресам РФ |
-| Маркеры | ✅ Кастомные, кластеризация, балуны (PlaceCard) |
-| Ограничения | ❌ Работает только в РФ и СНГ |
-| API | JS API v3, HTTP API геокодера |
-
-**Вердикт: основной провайдер для РФ**
-- Нативная поддержка российских адресов
-- Хорошая документация на русском
-- Простая кастомизация балунов под PlaceCard
-- Определение региона пользователя через JS API
-
-### 2. MapLibre GL JS + OpenStreetMap
+## Leaflet + OpenStreetMap
 
 | Характеристика | Значение |
 |----------------|----------|
-| Статус в РФ | ✅ Работает (через любые тайлы) |
-| Схематический стиль | ✅ Кастомный style.json (можно сделать схематичным) |
-| Бесплатный тариф | ✅ Open Source, бесплатно |
-| Маркеры | ✅ Кастомные, кластеризация |
-| Поиск адресов | ❌ Нужен отдельный сервис (Nominatim или DaData) |
-| Тайлы | MapTiler, Geoapify, или self-hosted |
-
-**Вердикт: резервный / международный провайдер**
-- Работает где угодно
-- Требует больше настройки
-- Для РФ нужен тайловый сервер с российскими тайлами (NextGIS, Geoapify)
-
-### 3. 2GIS Карты
-
-| Характеристика | Значение |
-|----------------|----------|
-| Статус в РФ | ✅ Работает |
-| Схематический стиль | ✅ |
-| Бесплатный тариф | ✅ До 50 000 запросов/мес |
-| API | JS API (онлайн), Map API (мобильные) |
-| Маркеры | ✅ Кастомные |
-| Детализация РФ | ✅ Отличная |
-
-**Вердикт: альтернатива для РФ, но API менее гибкий чем Яндекс**
-
-### 4. Leaflet + OpenStreetMap (через прокси)
-
-| Характеристика | Значение |
-|----------------|----------|
-| Статус в РФ | ✅ Работает |
-| Схематический стиль | ⚠️ Зависит от тайлов |
+| Статус | ✅ Работает |
+| Схематический стиль | ✅ OpenStreetMap тайлы |
 | Бесплатный тариф | ✅ |
-| Маркеры | ✅ Через плагины |
-
-**Вердикт: слишком базовая, многое нужно делать руками**
-
----
-
-## Итоговая стратегия
-
-```
-Определение страны пользователя
-├── РФ / СНГ → Яндекс Карты JS API v3
-└── Остальной мир → MapLibre GL JS + OSM (MapTiler)
-```
-
-### Определение региона (Geo-detection)
-1. **Backend**: заголовок Cloudflare `CF-IPCountry` или база GeoIP в Redis
-2. **Frontend**: `Intl.DateTimeFormat().resolvedOptions().timeZone` как запасной вариант
-3. **Ответ API** при загрузке страницы: `{ map_provider: "yandex" | "maplibre" }`
+| Маркеры | ✅ Leaflet API |
+| Геокодинг | Nominatim OpenStreetMap (backend `/api/v1/geocode/reverse`) |
 
-### План внедрения
-1. **MVP**: только Яндекс Карты (первый запуск только в РФ)
-2. **V2**: добавить MapLibre для международных пользователей
-3. Держать оба провайдера под одним интерфейсом через абстракцию `MapAdapter`
+**Leaflet** — основной и единственный провайдер карт.
+- OpenStreetMap тайлы (бесплатно, без ключа)
+- Геокодинг через Nominatim на backend (GeocodeReverse handler)
+- Адреса хранятся в БД в столбце `address` таблицы `places`

+ 3 - 3
backend/docs/SPEC.md

@@ -93,9 +93,9 @@
 
 ## Провайдеры карт
 
-- **Россия**: Яндекс Карты JS API v3 (схематический стиль `yandex#map`)
-- **Международные**: MapLibre GL JS + тайлы OSM (MapTiler / Geoapify)
-- **Определение региона**: по IP (Cloudflare) или часовому поясу браузера
+- **Карта**: Leaflet + OpenStreetMap (через Nominatim для геокодинга)
+- **Тайлы**: OpenStreetMap (бесплатно, без ключа)
+- **Геокодинг**: Nominatim OpenStreetMap API (GeocodeReverse handler)
 
 ## Платежи (CloudPayments)
 

+ 0 - 1
backend/internal/handlers/interfaces.go

@@ -29,7 +29,6 @@ type AuthService interface {
 
 type UserRepo interface {
 	GetByID(ctx context.Context, id string) (*models.User, error)
-	GetByEmail(ctx context.Context, email string) (*models.User, error)
 	Create(ctx context.Context, user *models.User) error
 	Update(ctx context.Context, user *models.User) error
 	UpdateRole(ctx context.Context, id, role string) error

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

@@ -77,6 +77,8 @@ func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) {
 		var b models.Bounds
 		if _, err := fmt.Sscanf(bounds, "%f,%f,%f,%f", &b.SWLat, &b.SWLng, &b.NELat, &b.NELng); err == nil {
 			filter.Bounds = &b
+		} else {
+			slog.Warn("invalid bounds format", "bounds", bounds, "error", err)
 		}
 	}
 	if limit := q.Get("limit"); limit != "" {

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

@@ -77,11 +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")
-	}
+	req := deleteTagRequest{ID: r.URL.Query().Get("id")}
 	if err := validator.Validate(req); err != nil {
 		writeValidationError(w, err)
 		return

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

@@ -3,6 +3,7 @@ package handlers
 
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
 	"strconv"
 
@@ -10,6 +11,7 @@ import (
 	"golang.org/x/crypto/bcrypt"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/repository"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/validator"
 )
 
@@ -109,16 +111,6 @@ func (h *UserHandler) AdminCreateUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	existing, err := h.userRepo.GetByEmail(r.Context(), req.Email)
-	if err != nil {
-		writeError(w, http.StatusInternalServerError, "failed to check existing email", err)
-		return
-	}
-	if existing != nil {
-		writeError(w, http.StatusConflict, "email already exists", nil)
-		return
-	}
-
 	hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to hash password", err)
@@ -133,6 +125,10 @@ func (h *UserHandler) AdminCreateUser(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if err := h.userRepo.Create(r.Context(), user); err != nil {
+		if errors.Is(err, repository.ErrEmailExists) {
+			writeError(w, http.StatusConflict, "email already exists", nil)
+			return
+		}
 		writeError(w, http.StatusInternalServerError, "failed to create user", err)
 		return
 	}
@@ -164,6 +160,12 @@ func (h *UserHandler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if req.Role != nil {
+		if currentUserRole != "superadmin" {
+			if *req.Role == "superadmin" {
+				writeError(w, http.StatusForbidden, "только superadmin может назначить superadmin", nil)
+				return
+			}
+		}
 		if id == currentUserID {
 			if currentUserRole != "superadmin" {
 				writeError(w, http.StatusForbidden, "вы не можете сменить свою роль", nil)

+ 1 - 1
backend/internal/middleware/auth.go

@@ -51,7 +51,7 @@ func AuthMiddleware(authSvc *services.AuthService) func(http.Handler) http.Handl
 func RoleMiddleware(roles ...string) func(http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			role := r.Context().Value(UserRoleKey).(string)
+			role, _ := r.Context().Value(UserRoleKey).(string)
 			for _, allowed := range roles {
 				if role == allowed {
 					next.ServeHTTP(w, r)

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

@@ -59,13 +59,13 @@ func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			ip := GetClientIP(r)
 			rl.mu.Lock()
+			defer rl.mu.Unlock()
 			limiter, ok := rl.visitors[ip]
 			if !ok {
 				limiter = rate.NewLimiter(rl.rate, rl.burst)
 				rl.visitors[ip] = limiter
 			}
 			rl.lastSeen[ip] = time.Now()
-			rl.mu.Unlock()
 
 			if !limiter.Allow() {
 				http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)

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

@@ -30,15 +30,19 @@ func (r *BookingRepo) Create(ctx context.Context, b *models.Booking) error {
 	defer tx.Rollback(ctx)
 
 	var hourlyRate *int
+	var placeStatus string
 	err = tx.QueryRow(ctx,
-		`SELECT hourly_rate, currency FROM places WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
-		b.PlaceID).Scan(&hourlyRate, &b.Currency)
+		`SELECT hourly_rate, currency, status FROM places WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
+		b.PlaceID).Scan(&hourlyRate, &b.Currency, &placeStatus)
 	if err != nil {
 		if errors.Is(err, pgx.ErrNoRows) {
 			return ErrPlaceNotFound
 		}
 		return fmt.Errorf("lock place: %w", err)
 	}
+	if placeStatus != "published" {
+		return fmt.Errorf("place is not available for booking")
+	}
 
 	if hourlyRate != nil {
 		hours := math.Ceil(b.EndTime.Sub(b.StartTime).Hours())

+ 15 - 3
backend/internal/repository/users.go

@@ -3,14 +3,18 @@ package repository
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"time"
 
 	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
 	"github.com/jackc/pgx/v5/pgxpool"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
 )
 
+var ErrEmailExists = errors.New("email already exists")
+
 type UserRepo struct {
 	pool *pgxpool.Pool
 }
@@ -20,12 +24,20 @@ func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
 }
 
 func (r *UserRepo) Create(ctx context.Context, u *models.User) error {
-	return r.pool.QueryRow(ctx,
-		`INSERT INTO users (email, password_hash, role, name)
-		 VALUES ($1, $2, $3, $4)
+	err := r.pool.QueryRow(ctx,
+		`INSERT INTO users (email, password_hash, role, name, status)
+		 VALUES ($1, $2, $3, $4, 'active')
 		 RETURNING id, created_at, updated_at`,
 		u.Email, u.PasswordHash, u.Role, u.Name,
 	).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
+	if err != nil {
+		var pgErr *pgconn.PgError
+		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+			return ErrEmailExists
+		}
+		return fmt.Errorf("create user: %w", err)
+	}
+	return nil
 }
 
 func (r *UserRepo) GetByID(ctx context.Context, id string) (*models.User, error) {

+ 5 - 10
backend/internal/services/auth.go

@@ -13,6 +13,7 @@ import (
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/log"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/pointer"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/repository"
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -82,16 +83,6 @@ type RegisterInput struct {
 func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthResult, string, error) {
 	logger := log.FromContext(ctx)
 
-	existing, err := s.userRepo.GetByEmail(ctx, input.Email)
-	if err != nil {
-		logger.ErrorContext(ctx, "failed to check existing email", log.WithError(err))
-		return nil, "", fmt.Errorf("check existing email: %w", err)
-	}
-	if existing != nil {
-		logger.WarnContext(ctx, "registration attempt with existing email", slog.String("email", input.Email))
-		return nil, "", ErrEmailExists
-	}
-
 	hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
 	if err != nil {
 		logger.ErrorContext(ctx, "password hashing failed", log.WithError(err))
@@ -106,6 +97,10 @@ func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthR
 	}
 
 	if err := s.userRepo.Create(ctx, user); err != nil {
+		if errors.Is(err, repository.ErrEmailExists) {
+			logger.WarnContext(ctx, "registration attempt with existing email", slog.String("email", input.Email))
+			return nil, "", ErrEmailExists
+		}
 		logger.ErrorContext(ctx, "user creation failed", log.WithError(err), slog.String("email", input.Email))
 		return nil, "", fmt.Errorf("create user: %w", err)
 	}

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

@@ -123,8 +123,6 @@ services:
       args:
         NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
         NEXT_PUBLIC_MAP_PROVIDER: leaflet
-        NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ${YANDEX_MAPS_API_KEY}
-        NEXT_PUBLIC_YANDEX_GEOCODER_API_KEY: ${YANDEX_GEOCODER_API_KEY}
         NEXT_PUBLIC_GIT_HASH: ${NEXT_PUBLIC_GIT_HASH}
     depends_on:
       backend:

+ 0 - 2
deploy/env.prod.example

@@ -30,9 +30,7 @@ CLOUDPAYMENTS_API_SECRET=
 
 # ----- Frontend -----
 NEXT_PUBLIC_API_URL=https://api.photoplaces.ru/api/v1
-NEXT_PUBLIC_MAP_PROVIDER=yandex
 NEXT_PUBLIC_GIT_HASH=
-YANDEX_MAPS_API_KEY=
 
 # ----- CORS (comma-separated domains) -----
 ALLOWED_ORIGINS=https://photoplaces.ru,https://www.photoplaces.ru

+ 0 - 3
docker-compose.yml

@@ -85,7 +85,6 @@ services:
         condition: service_healthy
     volumes:
       - ./backend:/app
-      - backend-modules:/app/node_modules
     healthcheck:
       test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"]
       interval: 10s
@@ -101,8 +100,6 @@ services:
       - "3000:3000"
     environment:
       NEXT_PUBLIC_API_URL: http://localhost:8080/api/v1
-      NEXT_PUBLIC_MAP_PROVIDER: yandex
-      NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ""
       NEXT_PUBLIC_GIT_HASH: ${NEXT_PUBLIC_GIT_HASH:-dev}
     depends_on:
       backend:

+ 0 - 2
frontend/.env.local.example

@@ -1,3 +1 @@
 NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
-NEXT_PUBLIC_MAP_PROVIDER=yandex
-NEXT_PUBLIC_YANDEX_MAPS_API_KEY=

+ 1 - 0
frontend/next.config.js

@@ -5,6 +5,7 @@ const nextConfig = {
     remotePatterns: [
       { protocol: 'https', hostname: 'cdn.photoplaces.ru' },
       { protocol: 'http', hostname: 'localhost', port: '9000' },
+      { protocol: 'http', hostname: '192.168.88.128', port: '80' },
     ],
   },
 }

+ 1 - 4
frontend/src/components/ErrorBoundary.tsx

@@ -23,10 +23,7 @@ export class ErrorBoundary extends Component<Props, State> {
   }
 
   componentDidCatch(error: Error, info: ErrorInfo) {
-    if (process.env.NODE_ENV === 'production') {
-      return
-    }
-    console.debug('ErrorBoundary caught:', error.message, info.componentStack)
+    console.error('ErrorBoundary caught:', error.message, info.componentStack)
   }
 
   render() {

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

@@ -34,8 +34,9 @@ export default function MapView() {
 
       const res = await api.get<{ data: Place[] }>(`/places?${params}`)
       setPlaces(res.data)
-    } catch {
-      // Ошибка загрузки мест — карта продолжает работать с текущими данными
+    } catch (e) {
+      console.warn('failed to fetch places', e)
+      setMapError(true)
     }
   }, [])
 

+ 3 - 1
frontend/src/hooks/useAuth.tsx

@@ -62,7 +62,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const logout = useCallback(async () => {
     try {
       await api.post('/auth/logout', {})
-    } catch {}
+    } catch (e) {
+      console.warn('logout failed', e)
+    }
     setAccessToken(null)
     setUser(null)
   }, [])

+ 1 - 1
frontend/src/lib/api.ts

@@ -68,7 +68,7 @@ async function request<T>(
     credentials: 'include',
   })
 
-  const noRefreshPaths = ['/auth/login', '/auth/register', '/auth/refresh']
+  const noRefreshPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout']
   if (res.status === 401 && !isRetry && !noRefreshPaths.includes(path)) {
     try {
       const newToken = await refreshAccessToken()

+ 27 - 17
obsidian_data/Photoplaces_data/architecture-overview.md

@@ -43,14 +43,19 @@ graph TB
     CADDY --> S3
 ```
 
-## Что было исправлено с момента первой версии Obsidian-заметок
+## Статус исправления проблем
 
-| Проблема (из старой заметки) | Статус | Где исправлено |
+| Проблема | Статус | Где |
 |---|---|---|
 | Refresh tokens не хранятся в БД | ✅ Исправлено | `services/auth.go` — SHA-256, ротация, `DeleteIfExists` |
 | Rate limiter только in-memory | ✅ Исправлено | `main.go` — Redis + fallback, fail-open/closed |
 | Нет валидации входных данных | ✅ Исправлено | `validator/validator.go` — go-playground/validator |
 | Нет тестов | ✅ Частично | `auth_test.go`, `places_test.go`, `place_repo_test.go` |
+| Package-level mutable state | ✅ Исправлено | `handlers/errors.go` — `atomic.Bool` вместо `var AppEnv string` |
+| Координаты [lng,lat]/[lat,lng] | ✅ Исправлено | `lib/map.ts` — консистентно (lat, lng) везде |
+| N+1 в GetByID | ✅ Исправлено | `repository/places.go:63-109` — один LEFT JOIN + `json_agg` |
+| Подавление ошибок PlaceForm | ✅ Исправлено | `components/PlaceForm.tsx:50` — ошибки через `setError` |
+| Мало линтеров | ✅ Исправлено | `.golangci.yml` — добавлены `gosec`, `bodyclose`, `gocritic`, `revive` |
 
 ## Актуальные проблемы (на 2026-06)
 
@@ -58,30 +63,35 @@ graph TB
 
 | Проблема | Файл | Описание |
 |---|---|---|
-| **Package-level mutable state** | `handlers/errors.go:13` | `var AppEnv string` — глобальная переменная, невозможно тестировать хендлеры |
-| **TOCTOU race при регистрации** | `services/auth.go:85-86` | `GetByEmail` → `Create` без транзакции, ошибка подавлена через `_` |
+| **TOCTOU race при регистрации** | `services/auth.go:85-111` | `GetByEmail` → `Create` без транзакции |
 | **Mutex без defer** | `middleware/ratelimit.go:61` | `rl.mu.Lock()` без `defer Unlock()` — deadlock при панике |
-| **Координаты карты: [lng,lat] vs [lat,lng]** | `lib/map.ts` | Интерфейс `init()` принимает `[lng, lat]`, `setCenter()` — `[lat, lng]` |
+| **Эскалация привилегий** | `handlers/users.go:166-186` | Модератор может повысить любого до superadmin |
 
 ### P1 — Высокие
 
 | Проблема | Файл | Описание |
 |---|---|---|
-| N+1 запросы в GetByID | `services/places.go:112-136` | 3 отдельных запроса вместо JOIN |
-| Лишние запросы при пагинации | `services/places.go:148-168` | Tags/features подгружаются для отбрасываемого `limit+1` элемента |
-| Подавление ошибок API на фронте | `components/PlaceForm.tsx:42-43` | `.catch(() => {})` — пользователь не видит ошибок загрузки тегов |
-| Retry loop на /auth/logout | `lib/api.ts:71` | `!path.includes('/auth/')` не исключает `/auth/logout` |
-| Хрупкий парсинг bounds | `handlers/places.go:55-59` | `fmt.Sscanf` без проверки ошибки |
+| Retry loop на /auth/logout | `lib/api.ts:71` | `/auth/logout` не в `noRefreshPaths` — logout с истёкшим токеном циклит refresh |
+| Лишние запросы при пагинации | `services/places.go:148-172` | `GetTagsBatch`/`GetFeaturesBatch` вызываются до обрезки `limit+1` |
+| Хрупкий парсинг bounds | `handlers/places.go:76-80` | `fmt.Sscanf` — silent fail при ошибке |
+| Отсутствует статус по умолчанию у user | `repository/users.go:22-29` | INSERT без `status` — новый пользователь имеет пустой статус |
+| Нет проверки статуса place при бронировании | `repository/bookings.go:25-63` | Можно забронировать место в `draft`/`pending_moderation` |
+| RoleMiddleware: unsafe type assertion | `middleware/auth.go:54` | `ctx.Value(UserRoleKey).(string)` запаникует при не-строке |
 
 ### P2 — Умеренные
 
-| Проблема | Описание |
-|---|---|
-| ErrorBoundary молчит в production | `console.debug` в проде не вызывается — ошибки не логируются |
-| Полная перерисовка маркеров | Все маркеры удаляются и создаются заново при каждом изменении `places` |
-| node_modules volume в Go backend | `docker-compose.yml:88` — не имеет смысла |
-| CSP с `unsafe-inline` и `unsafe-eval` | Caddyfile — ослабляет XSS-защиту |
-| Мало линтеров в `.golangci.yml` | Нет `gosec`, `bodyclose`, `gocritic`, `revive` |
+| Проблема | Файл | Описание |
+|---|---|---|
+| ErrorBoundary молчит в production | `components/ErrorBoundary.tsx:26-29` | `if (NODE_ENV === 'production') return` — ошибки не логируются |
+| Полная перерисовка маркеров | `components/MapView.tsx:98-115` | Все маркеры удаляются и создаются заново при каждом изменении `places` |
+| node_modules volume в Go backend | `docker-compose.yml:88` | `backend-modules:/app/node_modules` — не имеет смысла для Go |
+| CSP с `unsafe-inline` | `deploy/Caddyfile:39` | `script-src` и `style-src` с `unsafe-inline` — ослабляет XSS-защиту |
+| N+1 в services/GetByID | `handlers/services.go:80-85` | Отдельный запрос GetTags после GetByID |
+| DeleteTag: парсинг body | `handlers/tags.go:79-84` | DELETE читает JSON body, затем fallback на query |
+| broadcastVisitors: двойной lock | `handlers/websocket.go:91-127` | Состояние может измениться между сбором и рассылкой |
+| logout на фронте — подавление ошибок | `hooks/useAuth.tsx:65` | `catch {}` скрывает ошибки сети |
+| fetchPlaces — подавление ошибок | `components/MapView.tsx:37-39` | Ошибка загрузки мест не отображается пользователю |
+| next.config.js: нет S3-прокси для image optimization | `next.config.js:5-8` | Изображения через `api.{DOMAIN}/s3/` не оптимизируются |
 
 ## Архитектурные решения (Decision Records)
 

+ 82 - 0
obsidian_data/Photoplaces_data/atomic-code-audit-2026-06-30.md

@@ -0,0 +1,82 @@
+# Code Audit 2026-06-30 — Новые находки
+
+**Контекст**: Полный аудит кодовой базы после исправления предыдущего техдолга. Часть проблем исправлена, но обнаружены новые.
+
+## Суть
+
+Аудит выявил 10 новых проблем (1 security, 3 reliability, 6 code quality) в дополнение к уже известным из [[architecture-overview]].
+
+## Найденные проблемы
+
+### 🔴 Security
+1. **Эскалация привилегий через AdminUpdateUser** — `handlers/users.go:166-186`
+   - Модератор может повысить роль любого пользователя до `superadmin` (кроме своей). Ограничение только на собственную роль.
+   - Надо добавить проверку: модератор не может устанавливать роль `superadmin`.
+
+### 🟡 Reliability
+2. **Отсутствует статус по умолчанию при создании пользователя** — `repository/users.go:22-29`
+   - INSERT не задаёт `status`, в БД нет DEFAULT. У нового пользователя `Status = ""`.
+   - Миграция должна задавать `status DEFAULT 'active'`, либо код должен явно проставлять.
+   
+3. **BookingRepo.Create не проверяет статус места** — `repository/bookings.go:25-63`
+   - Можно забронировать место в статусе `pending_moderation`, `draft` или `rejected`. Проверка только на `deleted_at IS NULL`.
+   - Нужна проверка `status = 'published'`.
+
+4. **RoleMiddleware type assertion без проверки** — `middleware/auth.go:54`
+   - `r.Context().Value(UserRoleKey).(string)` запаникует, если в контексте не строка.
+   - В отличие от безопасного `GetUserRole()`, эта функция делает unsafe type assertion.
+
+### 🔵 Code Quality
+5. **N+1 в GetByID для services** — `handlers/services.go:80-85`
+   - Сначала `GetByID`, потом отдельно `GetTags`. Можно объединить в один JOIN-запрос.
+   - Аналогично было с places, но там уже исправлено.
+
+6. **DeleteTag: странный парсинг тела DELETE** — `handlers/tags.go:79-84`
+   - Сначала JSON body, при ошибке — query param. Это не RESTful и путает.
+   - DELETE с body — редкая практика, лучше только query.
+
+7. **broadcastVisitors двойной lock без необходимости** — `handlers/websocket.go:91-127`
+   - Сначала под mutex собирает список, отпускает, потом снова берёт для рассылки.
+   - Между сбором и рассылкой список клиентов может измениться.
+
+8. **logout на фронте подавляет ошибки** — `hooks/useAuth.tsx:65`
+   - `catch {}` скрывает ошибки сети при logout.
+   - Пользователь не видит, что выход не удался.
+
+9. **fetchPlaces подавляет ошибку** — `components/MapView.tsx:37-39`
+   - Ошибка загрузки мест игнорируется, пользователь не видит уведомления.
+
+10. **next.config.js: только cdn.photoplaces.ru и localhost** — `next.config.js:5-8`
+    - В production на тестовом сервере S3/MinIO раздаётся через `api.{DOMAIN}/s3/`, что не добавлено в `remotePatterns`.
+    - Изображения могут не загружаться через Next.js Image Optimization.
+
+## Что было проверено и признано корректным
+
+- Валидация на всех эндпоинтах (go-playground/validator)
+- Безопасность паролей (bcrypt)
+- Защита от SQL-инъекций (pgx параметризованные запросы)
+- Refresh token rotation + SHA-256 хранение
+- Rate limiter с fail-open/closed поддержкой
+- Проверка сигнатуры файлов при загрузке (magic bytes)
+- CORS настроен
+- Мягкое удаление (soft delete)
+- Graceful shutdown
+- Консистентность координат (lat, lng)
+
+## Рекомендации
+
+1. **Срочно (P0)**: Добавить проверку в `AdminUpdateUser` — модератор не может ставить роль superadmin
+2. **Высоко (P1)**: Добавить DEFAULT 'active' для status в users (миграция 000012)
+3. **Высоко (P1)**: Добавить проверку `status = 'published'` при создании брони
+4. **Средне (P2)**: Исправить RoleMiddleware, N+1 в services, broadcastVisitors lock, next.config.js
+
+## Связанные заметки
+
+- [[architecture-overview]]
+- [[atomic-toctou-race-registration]]
+- [[atomic-mutex-defer-panic]]
+- [[atomic-error-swallowing-frontend]]
+
+## Теги
+
+#code-review #security #backend #frontend #bug #2026-06

+ 5 - 6
obsidian_data/Photoplaces_data/atomic-csp-configuration.md

@@ -1,28 +1,27 @@
 ## Content Security Policy для SPA с картами
 
-**Контекст:** Фронтенд использует Яндекс.Карты (загрузка скриптов и стилей с api-maps.yandex.ru). Без CSP сайт уязвим к XSS-атакам.
+**Контекст:** CSP настраивается в Caddyfile (reverse proxy) и блокирует неразрешённые источники.
 
-**Суть:** CSP настраивается в Caddyfile (reverse proxy) и блокирует неразрешённые источники.
+**Суть:** Фронтенд использует Leaflet с тайлами OpenStreetMap (без внешних скриптов карт).
 
 ```
 Content-Security-Policy: default-src 'self';
-    script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api-maps.yandex.ru;
+    script-src 'self' 'unsafe-inline';
     style-src 'self' 'unsafe-inline';
     img-src 'self' data: blob: https:;
     font-src 'self' data:;
-    connect-src 'self' wss: https://api.{$DOMAIN} https://api-maps.yandex.ru;
+    connect-src 'self' wss: https://api.{$DOMAIN};
     frame-ancestors 'none';
 ```
 
 **Почему именно так:**
 - `'unsafe-inline'` для script — требуется Next.js (hydration)
-- `'unsafe-eval'` для script — требуется Яндекс.Картам
 - `frame-ancestors 'none'` — защита от clickjacking (дублирует X-Frame-Options)
 - `connect-src wss:` — для WebSocket посетителей
 
 **Что НЕ должно быть в production CSP:**
 - `'unsafe-inline'` для script лучше убрать, если Next.js в standalone режиме это позволяет
-- `https:` для img-src — можно сузить до конкретных доменов (cdn.photoplaces.ru, api-maps.yandex.ru)
+- `https:` для img-src — можно сузить до конкретных доменов (cdn.photoplaces.ru)
 
 **Связанные заметки:** [[deploy-production-readiness]], [[atomic-websocket-origin-check]]
 **Источник:** Настройка Caddyfile для production