Quellcode durchsuchen

fix: add crossOrigin anonymous to tileLayer for iOS Safari

neyrogovnarik vor 1 Monat
Ursprung
Commit
a1a101160e

+ 1 - 0
frontend/src/lib/map.ts

@@ -59,6 +59,7 @@ export function createLeafletMapProvider(): MapProvider {
 
         L.tileLayer(TILE_URL, {
           maxZoom: 19,
+          crossOrigin: 'anonymous',
         }).addTo(map)
 
         map.on('moveend', () => {

+ 8 - 4
obsidian_data/Photoplaces_data/.obsidian/workspace.json

@@ -11,10 +11,14 @@
             "id": "3d01ec58e39ece45",
             "type": "leaf",
             "state": {
-              "type": "graph",
-              "state": {},
-              "icon": "lucide-git-fork",
-              "title": "Граф"
+              "type": "markdown",
+              "state": {
+                "file": "architecture-overview.md",
+                "mode": "source",
+                "source": false
+              },
+              "icon": "lucide-file",
+              "title": "architecture-overview"
             }
           }
         ]

+ 47 - 17
obsidian_data/Photoplaces_data/MOC-backend-patterns.md

@@ -17,15 +17,21 @@ graph TB
 ### Ключевые решения
 
 | Паттерн | Где реализовано | Заметка |
-|---------|----------------|---------|
+|---|---|---|
 | Sentinel errors | `services/*.go` | [[atomic-sentinel-errors-go]] |
-| Dependency Injection (интерфейсы) | `services/*.go` | [[atomic-dependency-inversion-go]] |
-| Graceful shutdown | `cmd/api/main.go` | [[atomic-graceful-shutdown]] |
+| Dependency Injection (интерфейсы) | `services/*.go`, `main.go` | [[atomic-dependency-inversion-go]] |
 | Refresh token rotation | `services/auth.go` | [[decision-jwt-refresh-storage]] |
-| Rate limiting (Redis) | `middleware/ratelimit_redis.go` | [[decision-rate-limiter-redis]] |
+| Rate limiting (Redis + fallback) | `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]] |
+| Refresh token race fix (`DeleteIfExists`) | `services/auth.go` | [[atomic-refresh-token-race-condition]] |
+| SSRF protection via MaxBytesReader | `handlers/*.go` | `http.MaxBytesReader(w, r.Body, 1MB)` |
+| Go-playground/validator | `validator/validator.go` | [[decision-validation-library]] |
+| Cursor-based pagination | `services/places.go` | base64 cursor, `limit+1` trick |
+| Graceful shutdown | `cmd/api/main.go` | `signal.Notify` + `server.Shutdown` с 30s timeout |
+| Fail-open/Fail-closed rate limiter | `main.go:119` | [[atomic-redis-rate-limiter-failopen-failclosed]] |
+| CSP nonce hardening | `Caddyfile` | [[atomic-csp-hardening]] |
+| Package-level mutable state (anti-pattern) | `handlers/errors.go:13` | [[atomic-global-appenv-package-var]] |
+| TOCTOU race (anti-pattern) | `services/auth.go:85-86` | [[atomic-toctou-race-registration]] |
 
 ### Error handling chain
 
@@ -36,29 +42,53 @@ sequenceDiagram
     Repo-->>Service: pgx error
     Service-->>Handler: sentinel error (%w)
     Handler->>Handler: errors.Is() -> HTTP status
+    alt production
+        Handler->>Handler: скрыть детали 500
+    else development
+        Handler->>Handler: вернуть как есть
+    end
 ```
 
 ### Request body size limit
 
 Все хендлеры, принимающие JSON, обёрнуты в `http.MaxBytesReader(w, r.Body, 1MB)`. Это предотвращает атаки исчерпания памяти через гигантские payloads.
 
-### Удаление неиспользуемых параметров
+### Утечка координат карты (frontend)
 
-Убраны `appEnv` из конструкторов 6 хендлеров, где он не использовался ([[atomic-unused-appenv-parameter]]).
+Обнаружена неконсистентность интерфейса `MapProvider`: `init()` принимает `[lng, lat]`, `setCenter()` — `[lat, lng]`. См. [[atomic-map-coordinates-inconsistency]].
 
-### Тестирование
+### Подавление ошибок (frontend)
 
-| Уровень | Инструмент | Пример |
-|---------|-----------|--------|
-| Unit (сервисы) | `testing` + моки | [[atomic-test-patterns-go]] |
-| Integration | `testcontainers-go` | [[decision-test-strategy]] |
-| E2E | Playwright | [[decision-test-strategy]] |
+В `PlaceForm.tsx`, `MapView.tsx`, `useWebSocket.ts` ошибки подавляются пустым `catch`. См. [[atomic-error-swallowing-frontend]].
+
+### Покрытие тестами
+
+| Уровень | Инструмент | Примеры |
+|---|---|---|
+| Unit (сервисы) | `testing` + моки | `auth_test.go` (6 тестов), `places_test.go` (3 теста) |
+| Integration | `pgxpool` + test DB | `place_repo_test.go` (4 теста) |
+| Frontend | Vitest + testing-library | `ErrorBoundary.test.tsx` (2 теста) |
+
+**Покрытие**: ~5%. Нужно расширять — особенно хендлеры и middleware.
+
+### Антипаттерны, найденные в code review
+
+| Антипаттерн | Место | Заметка |
+|---|---|---|
+| Package-level mutable state | `handlers/errors.go:13` | [[atomic-global-appenv-package-var]] |
+| TOCTOU race | `services/auth.go:85-86` | [[atomic-toctou-race-registration]] |
+| Mutex без defer | `middleware/ratelimit.go:61` | [[atomic-mutex-defer-panic]] |
+| N+1 запросы | `services/places.go:112-136` | [[atomic-n-plus-one-getbyid]] |
+| Silenced errors | `PlaceForm.tsx:42-43` | [[atomic-error-swallowing-frontend]] |
 
 ### Связанные заметки
-- [[architecture-overview]] — общая архитектура
+
+- [[architecture-overview]] — общая архитектура и таблица P0-P2
 - [[backend-auth-security]] — JWT и refresh token
-- [[backend-validation]] — валидация через go-playground/validator
+- [[backend-validation]] — валидация
 - [[backend-rate-limiting]] — rate limiting
 - [[deploy-production-readiness]] — production readiness
+- [[MOC-security-patterns]] — безопасность
+- [[database-migrations]] — схема БД
 
-#architecture #backend #golang #MOC #best-practice
+#architecture #backend #golang #MOC #best-practice #anti-pattern

+ 43 - 18
obsidian_data/Photoplaces_data/MOC-security-patterns.md

@@ -15,34 +15,59 @@ graph LR
 ### Ключевые решения
 
 | Область | Решение | Документ |
-|---------|---------|----------|
-| Refresh token storage | SHA-256 хеш в PostgreSQL | [[decision-jwt-refresh-storage]] |
+|---|---|---|
+| Refresh token storage | SHA-256 хеш в PostgreSQL + ротация | [[backend-auth-security]] |
 | 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]] |
+| Rate limiting | Redis + in-memory fallback, per-IP / per-UserID | [[backend-rate-limiting]] |
+| Fail-open vs Fail-closed | Зависит от `AppEnv` | [[atomic-redis-rate-limiter-failopen-failclosed]] |
+| WebSocket | per-connection rate limiter, глобальный лимит 1000 | [[atomic-websocket-origin-check]] |
 | Error exposure | Скрытие деталей в production (500 + validation) | [[decision-validation-error-production]] |
+| CSP | Content-Security-Policy через Caddy | [[atomic-csp-hardening]] |
+| TOCTOU race (anti-pattern) | Race при регистрации | [[atomic-toctou-race-registration]] |
+| Package-level state (anti-pattern) | `var AppEnv` глобальная | [[atomic-global-appenv-package-var]] |
 
 ### 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)
+- **CORS**: только разрешённые `AllowedOrigins`, валидация при старте (`config.go:106`)
+- **CSP**: Content-Security-Policy через Caddy, но с `unsafe-inline` — нужно hardening ([[atomic-csp-hardening]])
+- **JWT secrets**: `_FILE` suffix для Docker secrets, `panic` если в production = dev-secret
+- **Request body**: `http.MaxBytesReader` (1 MB лимит) на всех JSON-инпутах
+- **Rate limiter**: fail closed в production (503 при недоступности Redis)
+- **WebSocket**: проверка Origin, глобальный лимит 1000 соединений
+- **DB**: параметризованные запросы (pgx), soft delete через `deleted_at`
+- **File upload**: валидация content-type (jpeg, png, webp, heic), лимит 50MB
+
+### Антипаттерны (требуют исправления)
+
+| Проблема | Файл | Риск |
+|---|---|---|
+| `var AppEnv string` — глобальная mutable-переменная | `handlers/errors.go:13` | Нет тестов, race при параллельных запросах |
+| TOCTOU race в Register | `services/auth.go:85-86` | Дубликат email при одновременной регистрации |
+| Ошибка `GetByEmail` подавлена через `_` | `services/auth.go:85` | При недоступности БД — "email exists" вместо 500 |
+| CSP с `unsafe-inline` + `unsafe-eval` | `Caddyfile:40` | XSS-защита ослаблена |
 
 ### Проверки аудита
 
-1. Все ли секреты вынесены из кода? (✓ env vars / Docker secrets)
-2. Есть ли `_` в начале `TLS` в коде? (✓ нет прямых TLS настроек — всё через Caddy)
-3. Используются ли параметризованные запросы? (✓ pgx)
-4. Есть ли `any` или `interface{}` в публичных API? (✓ в `writeJSON(v interface{})` — допустимо, это транспорт)
-5. Тестируется ли security? (— пока нет, нужно добавлять)
+1. Все ли секреты вынесены из кода? (✓ env vars / Docker secrets, но ещё есть `dev-secret` в `.env.example`)
+2. Используются ли параметризованные запросы? (✓ pgx)
+3. Есть ли `any`/`interface{}` в публичных API? (✓ в `writeJSON(v interface{})` — допустимо, транспорт)
+4. Есть ли race условия? (⚠ `var AppEnv`, TOCTOU в Register)
+5. Тестируется ли security? (— нет, нужно добавлять)
+6. Скрыты ли детали ошибок в production? (✓ для 500 и validation)
+7. Есть ли rate limiting на sensitive endpoints? (✓ auth: 10/min, write: 10/min)
 
 ### Связанные заметки
 
-- [[architecture-overview]]
-- [[backend-auth-security]]
-- [[deploy-production-readiness]]
+- [[architecture-overview]] — таблица P0-P2
+- [[backend-auth-security]] — JWT и refresh token
+- [[backend-rate-limiting]] — rate limiting
+- [[backend-validation]] — валидация
+- [[deploy-production-readiness]] — production readiness
+- [[atomic-global-appenv-package-var]] — глобальная переменная
+- [[atomic-toctou-race-registration]] — TOCTOU race
+- [[atomic-error-swallowing-frontend]] — подавление ошибок
+- [[atomic-csp-hardening]] — CSP hardening
+- [[atomic-redis-rate-limiter-failopen-failclosed]] — fail-open/closed
 
-#security #MOC #backend #auth #production
+#security #MOC #backend #auth #production #anti-pattern

+ 78 - 23
obsidian_data/Photoplaces_data/architecture-overview.md

@@ -1,6 +1,8 @@
 # Архитектура PhotoPlaces — Обзор (MOC)
 
-**Контекст**: Карта технического долга и архитектурных решений проекта PhotoPlaces для оценки жизнеспособности и планирования рефакторинга.
+**Контекст**: Карта архитектуры и технического долга проекта PhotoPlaces. Используется для планирования рефакторинга и оценки жизнеспособности.
+
+**Статус**: Заметка актуализирована 2026-06-24 после code review. Предыдущая версия содержала устаревшие P0, которые уже исправлены в коде.
 
 ## Суть
 
@@ -11,46 +13,99 @@
 ### Backend (Go 1.22)
 - **Router**: Chi v5
 - **DB**: pgx/v5 (PostgreSQL + PostGIS)
-- **Auth**: JWT (access 15min + refresh 30d)
+- **Auth**: JWT (access 15min + refresh 30d, SHA-256 hash в БД, ротация)
+- **Rate Limiting**: Redis (sliding window) + in-memory fallback
+- **Validation**: go-playground/validator + кастомные правила
 - **Architecture**: Layered (handlers → services → repository)
-- **Migrations**: golang-migrate
+- **Migrations**: golang-migrate (7 миграций)
 
 ### Frontend (Next.js 14 App Router)
 - **State**: React Context (AuthProvider)
-- **API**: Custom fetch wrapper
-- **Maps**: Yandex Maps API
+- **API**: Custom fetch wrapper с auto-refresh токена
+- **Maps**: Leaflet (OSM dark tiles via CartoDB)
 - **Styling**: Tailwind CSS
+- **Testing**: Vitest + @testing-library/react
 
 ### Infrastructure
 - **Local**: `docker-compose.yml` (postgres, redis, minio, backend, frontend)
-- **Prod**: `deploy/docker-compose.prod.yml` + `deploy/deploy.sh`
+- **Prod**: `deploy/docker-compose.prod.yml` + Caddy reverse proxy
+- **CI/CD**: GitHub Actions (dependabot.yml)
+
+```mermaid
+graph TB
+    FB[Frontend Next.js 14] --> API[Go API :8080]
+    API --> PG[PostgreSQL + PostGIS]
+    API --> RD[Redis]
+    API --> S3[MinIO / S3]
+    FB --> FBStatic[Next.js Static]
+    CADDY[Caddy] --> FB
+    CADDY --> API
+    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` |
+
+## Актуальные проблемы (на 2026-06)
 
-## Критические проблемы (P0)
+### P0 — Критические
 
 | Проблема | Файл | Описание |
-|----------|------|----------|
-| **Refresh tokens не хранятся в БД** | `auth.go` | Refresh токены валидируются только по подписи, нет отзыва, нет ротации, утечка = полный доступ 30 дней |
-| **Rate limiter in-memory** | `ratelimit.go`, `main.go` | Не работает в кластере, сбрасывается при рестарте |
-| **Нет валидации входных данных** | handlers | Ручная проверка `if req.Title == ""`, нет схем валидации |
-| **Hardcoded secrets в dev** | `.env.example`, `docker-compose.yml` | `dev-secret`, `photoplaces_dev` — опасно если попадут в прод |
-| **Нет тестов** | Везде | 0% покрытие — нельзя рефакторить безопасно |
+|---|---|---|
+| **Package-level mutable state** | `handlers/errors.go:13` | `var AppEnv string` — глобальная переменная, невозможно тестировать хендлеры |
+| **TOCTOU race при регистрации** | `services/auth.go:85-86` | `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]` |
+
+### 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` без проверки ошибки |
+
+### 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` |
 
 ## Архитектурные решения (Decision Records)
 
 - [[decision-jwt-refresh-storage]] — Почему refresh tokens должны храниться в БД
-- [[decision-rate-limiter-redis]] — Переход на Redis-based rate limiting
-- [[decision-validation-library]] — Выбор библиотеки валидации (go-playground/validator)
-- [[decision-test-strategy]] — Стратегия тестирования (unit/integration/e2e)
+- [[decision-rate-limiter-redis]] — Redis-based rate limiting с fallback
+- [[decision-validation-library]] — go-playground/validator
+- [[decision-validation-error-production]] — Скрытие деталей ошибок в production
+- [[decision-test-strategy]] — Стратегия тестирования
 
 ## Связанные заметки
 
-- [[backend-auth-security]]
-- [[backend-rate-limiting]]
-- [[backend-validation]]
-- [[frontend-api-client]]
-- [[deploy-production-readiness]]
-- [[database-migrations]]
+- [[backend-auth-security]] — JWT + refresh token реализация
+- [[backend-rate-limiting]] — Rate limiting (Redis + in-memory)
+- [[backend-validation]] — Валидация
+- [[frontend-api-client]] — API client на фронте
+- [[deploy-production-readiness]] — Production readiness
+- [[database-migrations]] — Схема миграций
+- [[atomic-global-appenv-package-var]] — Проблема глобальной переменной
+- [[atomic-toctou-race-registration]] — TOCTOU race
+- [[atomic-map-coordinates-inconsistency]] — Координаты карты
+- [[atomic-error-swallowing-frontend]] — Подавление ошибок
+- [[atomic-redis-rate-limiter-failopen-failclosed]] — Fail-open/closed
+- [[atomic-csp-hardening]] — CSP hardening
 
 ## Теги
 
-#architecture #backend #frontend #security #technical-debt #moc
+#architecture #backend #frontend #security #technical-debt #moc

+ 121 - 0
obsidian_data/Photoplaces_data/atomic-csp-hardening.md

@@ -0,0 +1,121 @@
+# CSP Hardening: `unsafe-inline` и `unsafe-eval` в Caddyfile
+
+**Контекст**: В `deploy/Caddyfile:40` Content-Security-Policy использует `'unsafe-inline'` и `'unsafe-eval'` для script-src. Это ослабляет XSS-защиту.
+
+## Текущая политика
+
+```
+Content-Security-Policy:
+    default-src 'self';
+    script-src 'self' 'unsafe-inline' 'unsafe-eval';
+    style-src 'self' 'unsafe-inline';
+    img-src 'self' data: blob: https://*.basemaps.cartocdn.com;
+    font-src 'self' data:;
+    connect-src 'self' wss: https://api.{$DOMAIN};
+    frame-ancestors 'none';
+```
+
+## Почему так
+
+1. **`'unsafe-inline'` для скриптов**: Next.js использует inline-скрипты для гидратации и данных страницы (`__NEXT_DATA__`, `__NEXT_LOADED_PAGES__` и т.д.)
+2. **`'unsafe-eval'`**: Нужен для source maps в development. Next.js 14 также может использовать `eval()` для некоторых динамических импортов и Webpack HMR.
+3. **`'unsafe-inline'` для стилей**: Tailwind CSS использует JIT-генерацию стилей, которые вставляются через `<style>`.
+
+## Проблема
+
+`'unsafe-inline'` и `'unsafe-eval'` — самые опасные директивы CSP:
+- **XSS-атака** может выполнить произвольный inline-скрипт в браузере пользователя
+- CSP становится практически бесполезным для защиты от XSS
+
+## Решение (поэтапное)
+
+### Фаза 1: Переход на nonce-основанную CSP
+
+Next.js 14 поддерживает nonce через middleware:
+
+```ts
+// frontend/src/middleware.ts
+import { NextResponse } from 'next/server'
+import crypto from 'crypto'
+
+export function middleware(request: Request) {
+    const nonce = crypto.randomBytes(16).toString('base64')
+    const csp = [
+        `default-src 'self'`,
+        `script-src 'self' 'nonce-${nonce}'`,
+        `style-src 'self' 'nonce-${nonce}'`,
+        `img-src 'self' data: blob: https://*.basemaps.cartocdn.com`,
+        `font-src 'self' data:`,
+        `connect-src 'self' wss: https://api.{$DOMAIN}`,
+        `frame-ancestors 'none'`,
+    ].join('; ')
+    
+    const response = NextResponse.next()
+    response.headers.set('Content-Security-Policy', csp)
+    response.headers.set('X-Nonce', nonce)
+    return response
+}
+```
+
+### Фаза 2: strict-dynamic
+
+Когда nonce работает, можно перейти на `'strict-dynamic'`:
+
+```
+script-src 'self' 'nonce-{random}' 'strict-dynamic';
+```
+
+`'strict-dynamic'` говорит браузеру доверять только скриптам с nonce и скриптам, загруженным этими доверенными скриптами. Все inline-скрипты без nonce блокируются.
+
+### Фаза 3: CSP Reporting
+
+Добавить `report-uri` или `report-to` для сбора нарушений CSP без блокировки:
+
+```
+Content-Security-Policy-Report-Only:
+    ... ;
+    report-uri https://example.com/csp-report;
+```
+
+## Альтернативы
+
+| Подход | Уровень защиты | Сложность внедрения |
+|---|---|---|
+| **Nonce + middleware** | Высокий | Средняя — нужно модифицировать Next.js |
+| **strict-dynamic + nonce** | Очень высокий | Высокая — ломает сторонние скрипты |
+| **Хеши** (`'sha256-...'`) | Средний | Низкая — но хеши меняются при каждом деплое |
+| **Report-Only (начать с этого)** | Мониторинг | Низкая — не блокирует, только логирует |
+
+## Текущий риск
+
+Для production: XSS через внедрение inline-скрипта. Если злоумышленник найдёт XSS-уязвимость, CSP не остановит его.
+
+## Что уже защищает
+
+- `HttpOnly` cookie для refresh token
+- `SameSite=Strict` для CSRF
+- `frame-ancestors 'none'` — защита от clickjacking
+- `X-Content-Type-Options: nosniff`
+- `X-Frame-Options: DENY`
+
+## Треккинг
+
+- [ ] Фаза 1: Middleware с nonce
+- [ ] Фаза 2: strict-dynamic
+- [ ] Фаза 3: CSP Reporting
+- [ ] Удалить `unsafe-inline` и `unsafe-eval`
+
+## Связанные заметки
+
+- [[MOC-security-patterns]] — общая карта безопасности
+- [[backend-auth-security]] — HttpOnly cookie
+- [[deploy-production-readiness]] — production hardening
+- [[atomic-csrf-protection]] — CSRF защита
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `deploy/Caddyfile:40`.
+
+## Теги
+
+#security #csp #frontend #nextjs #production #hardening

+ 116 - 0
obsidian_data/Photoplaces_data/atomic-error-swallowing-frontend.md

@@ -0,0 +1,116 @@
+# Подавление ошибок на фронтенде: пустой catch
+
+**Контекст**: В нескольких компонентах фронтенда ошибки API и браузерных API подавляются пустым `catch` или callback-заглушкой. Это скрывает проблемы от пользователя и разработчика.
+
+## Проблемные места
+
+### 1. Загрузка тегов и фич — PlaceForm.tsx
+
+```ts
+// PlaceForm.tsx:42-43 — ❌ пользователь не видит ошибку
+useEffect(() => {
+    api.get<Tag[]>('/tags').then(setTags).catch(() => {})       // подавлено
+    api.get<Feature[]>('/features').then(setFeatures).catch(() => {})  // подавлено
+}, [])
+```
+
+**Последствия**: Если API /tags или /features недоступен, форма рендерится без тегов/фич. Пользователь видит пустые секции и не понимает, почему.
+
+### 2. Геолокация — MapView.tsx
+
+```ts
+// MapView.tsx:57-62 — ❌ пользователь отказал — молча
+navigator.geolocation.getCurrentPosition(
+    (pos) => provider.setCenter(pos.coords.latitude, pos.coords.longitude),
+    () => {},  // ошибка подавлена
+)
+```
+
+**Последствия**: Если пользователь запретил геолокацию, карта остаётся на Москве (default coordinates). Пользователь не понимает, почему карта не центрируется на нём.
+
+### 3. WebSocket malformed message — useWebSocket.ts
+
+```ts
+// useWebSocket.ts:65 — ❌ malformed message логи не попадают
+} catch { /* ignore malformed */ }
+```
+
+**Последствия**: Если сервер шлёт битые данные, разработчик не узнает об этом. Для resilience это ок, но должен быть debug-лог.
+
+### 4. Logout API — useAuth.tsx
+
+```ts
+// useAuth.tsx:62-65 — ❌ ошибка logout скрыта
+const logout = useCallback(async () => {
+    try { await api.post('/auth/logout', {}) } catch {}  // подавлено
+    setAccessToken(null)
+    setUser(null)
+}, [])
+```
+
+**Последствия**: Локальный logout происходит в любом случае (что правильно), но если сервер вернул ошибку, мы не узнаем. Админ не увидит, что сессия не была полностью завершена на сервере.
+
+## Когда подавление ошибок — это ок
+
+| Сценарий | Статус | Почему |
+|---|---|---|
+| WebSocket malformed message | ✅ Приемлемо | Resilience — не хотим падать при битых данных. Но нужен debug-лог. |
+| Logout API error | ✅ Приемлемо | Force logout даже при недоступности сервера. |
+| Загрузка тегов/фич | ❌ Недопустимо | UI становится неполным без явной индикации. |
+| Геолокация отказана | ❌ Недопустимо | UX страдает — карта на неверном центре. |
+
+## Решение
+
+### Для API запросов (PlaceForm):
+
+```ts
+useEffect(() => {
+    api.get<Tag[]>('/tags')
+        .then(setTags)
+        .catch((err) => {
+            console.error('Failed to load tags:', err)
+            setTagsError('Не удалось загрузить теги')
+        })
+    api.get<Feature[]>('/features')
+        .then(setFeatures)
+        .catch((err) => {
+            console.error('Failed to load features:', err)
+            setFeaturesError('Не удалось загрузить характеристики')
+        })
+}, [])
+```
+
+И добавить состояние `tagsError`/`featuresError` для отображения пользователю.
+
+### Для геолокации (MapView):
+
+```ts
+navigator.geolocation.getCurrentPosition(
+    (pos) => provider.setCenter(pos.coords.latitude, pos.coords.longitude),
+    (err) => {
+        console.warn('Geolocation denied or unavailable:', err.message)
+        // Опционально: показать toast "Включите геолокацию для центрирования карты"
+    },
+)
+```
+
+## Принцип
+
+> Подавляй ошибки только если:
+> 1. Приложение может корректно работать без этого действия
+> 2. Пользователь не заметит потери функциональности
+> Иначе — хотя бы логируй и показывай пользователю.
+
+## Связанные заметки
+
+- [[architecture-overview]] — таблица P1
+- [[frontend-api-client]] — API клиент
+- [[MOC-backend-patterns]]
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `PlaceForm.tsx:42-43`, `MapView.tsx:60`, `useWebSocket.ts:65`, `useAuth.tsx:63`.
+
+## Теги
+
+#frontend #error-handling #best-practice #ux

+ 71 - 0
obsidian_data/Photoplaces_data/atomic-global-appenv-package-var.md

@@ -0,0 +1,71 @@
+# Package-level mutable state: var AppEnv
+
+**Контекст**: В `backend/internal/handlers/errors.go:13` объявлена глобальная переменная `var AppEnv string`, которая устанавливается из `cmd/api/main.go:68`. Используется для контроля раскрытия деталей ошибок в production.
+
+## Проблема
+
+```go
+// handlers/errors.go — ❌ package-level mutable state
+var AppEnv string
+
+func writeError(w http.ResponseWriter, status int, msg string, err error) {
+    if status == http.StatusInternalServerError && AppEnv == "production" {
+        msg = "internal server error"
+    }
+    writeJSON(w, status, map[string]string{"error": msg})
+}
+```
+
+```go
+// cmd/api/main.go:68 — установка глобальной переменной
+handlers.AppEnv = cfg.AppEnv
+```
+
+**Антипаттерн**:
+1. **Невозможно тестировать** — хендлеры зависят от внешнего состояния. Для теста `writeError` в production-режиме нужно установить `AppEnv = "production"`, а потом сбросить. При параллельных тестах — race condition.
+2. **Неявная связь** — любой хендлер может изменить `AppEnv` в любой момент. Нет гарантии консистентности.
+3. **Нарушение DI** — вместо явной передачи зависимости — глобальный мутабельный синглтон.
+
+## Решение
+
+Передавать `isProd` через конструктор хендлера (как уже сделано в `AuthHandler`):
+
+```go
+type PlaceHandler struct {
+    placeSvc *services.PlaceService
+    isProd   bool  // ✅ явная зависимость
+}
+
+func NewPlaceHandler(placeSvc *services.PlaceService, isProd bool) *PlaceHandler {
+    return &PlaceHandler{placeSvc: placeSvc, isProd: isProd}
+}
+
+func (h *PlaceHandler) writeError(w http.ResponseWriter, status int, msg string, err error) {
+    if status == http.StatusInternalServerError && h.isProd {
+        msg = "internal server error"
+    }
+    writeJSON(w, status, map[string]string{"error": msg})
+}
+```
+
+## Trade-offs
+
+| Подход | Плюсы | Минусы |
+|---|---|---|
+| **DI через конструктор** (рекомендую) | Тестируемо, явно, потокобезопасно | Нужно менять все хендлеры |
+| **Context values** | Не меняет сигнатуры хендлеров | Неявно, `.Value()` без проверки типа |
+| **Оставить как есть (status quo)** | Нет работы | Нет тестов, race condition |
+
+## Связанные заметки
+
+- [[backend-validation]] — где используется `AppEnv`
+- [[architecture-overview]] — таблица P0
+- [[MOC-backend-patterns]]
+
+## Источник
+
+Code review PhotoPlaces 2026-06. Найдено в `handlers/errors.go:13`.
+
+## Теги
+
+#architecture #anti-pattern #testing #golang #clean-code

+ 81 - 0
obsidian_data/Photoplaces_data/atomic-map-coordinates-inconsistency.md

@@ -0,0 +1,81 @@
+# [lng, lat] vs [lat, lng] — неконсистентный порядок координат в MapProvider
+
+**Контекст**: В `frontend/src/lib/map.ts` интерфейс `MapProvider` имеет методы, ожидающие разный порядок координат: `init()` принимает `[lng, lat]`, а `setCenter()` — `[lat, lng]`.
+
+## Проблема
+
+```ts
+// lib/map.ts — интерфейс (не документирует порядок!)
+interface MapProvider {
+    init(container: HTMLElement, center: [number, number], zoom: number): Promise<void>
+    setCenter(lat: number, lng: number): void
+    onMove(cb: (center: [number, number], zoom: number, bounds: MapBounds) => void): void
+}
+```
+
+```ts
+// lib/map.ts — реализация init: ожидает [lng, lat]
+init(container, center, zoom) {
+    map = L.map(container).setView([center[1], center[0]], zoom) // 🌀 swap
+}
+
+// lib/map.ts — реализация setCenter: ожидает [lat, lng]
+setCenter(lat, lng) {
+    map?.setView([lat, lng]) // без swap
+}
+
+// onMove callback: возвращает [lng, lat]
+moveHandler([c.lng, c.lat], map.getZoom(), bounds)
+```
+
+```ts
+// MapView.tsx — вызов: передаёт [lng, lat] (Moscow)
+provider.init(containerRef.current, [37.6173, 55.7558], 10)
+```
+
+**Почему это проблема**:
+1. `init()` принимает `[lng, lat]`, но `setCenter()` — `[lat, lng]`
+2. `onMove()` возвращает `[lng, lat]` — если кто-то использует `center` из колбэка, он получит неожиданный порядок
+3. Тип `[number, number]` не документирует порядок — новый разработчик гарантированно ошибётся
+
+## Решение
+
+**Вариант A (рекомендую)**: Унифицировать через именованный объект `{ lat, lng }`:
+
+```ts
+interface LatLng { lat: number; lng: number }
+
+interface MapProvider {
+    init(container: HTMLElement, center: LatLng, zoom: number): Promise<void>
+    setCenter(center: LatLng): void
+    onMove(cb: (center: LatLng, zoom: number, bounds: MapBounds) => void): void
+}
+```
+
+**Вариант B**: Строгий тип с документированным порядком:
+
+```ts
+/** [lng, lat] — внимание: порядок как в GeoJSON! */
+type LngLat = [lng: number, lat: number]
+
+interface MapProvider {
+    init(container: HTMLElement, center: LngLat, zoom: number): Promise<void>
+}
+```
+
+## Почему возникло
+
+Leaflet использует `[lat, lng]`. GeoJSON использует `[lng, lat]`. API-дизайнеры часто путаются. В проекте был взят GeoJSON-стиль для `init()` (потому что bounds приходят как "lng,lat" из API), но `setCenter()` остался в Leaflet-стиле.
+
+## Связанные заметки
+
+- [[architecture-overview]] — таблица P0
+- [[frontend-api-client]] — API клиент
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `lib/map.ts` и `MapView.tsx`.
+
+## Теги
+
+#frontend #leaflet #maps #api-design #inconsistency #clean-code

+ 77 - 0
obsidian_data/Photoplaces_data/atomic-mutex-defer-panic.md

@@ -0,0 +1,77 @@
+# Mutex без defer — deadlock при панике
+
+**Контекст**: В `backend/internal/middleware/ratelimit.go:61` мьютекс блокируется без `defer Unlock()`. Если код между Lock и Unlock запаникует, мьютекс останется заблокированным навсегда.
+
+## Проблема
+
+```go
+// ratelimit.go:57-76 — ❌ нет defer
+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 := GetClientIP(r)
+            rl.mu.Lock()
+            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()  // ❌ если паника в GetClientIP, NewLimiter или другой строке — deadlock
+
+            if !limiter.Allow() {
+                http.Error(w, `{"error":"rate limit exceeded"}`, 429)
+                return
+            }
+            next.ServeHTTP(w, r)
+        })
+    }
+}
+```
+
+## Решение
+
+```go
+// ✅ defer гарантирует разблокировку даже при панике
+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 := 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()
+
+            if !limiter.Allow() {
+                http.Error(w, `{"error":"rate limit exceeded"}`, 429)
+                return
+            }
+            next.ServeHTTP(w, r)
+        })
+    }
+}
+```
+
+**Почему Go-сообщество требует `defer`**: мьютекс должен быть разблокирован в любом сценарии — нормальное завершение, return, паника. `defer` гарантирует это.
+
+## Исключение
+
+Единственный случай, когда `defer` не обязателен — если между Lock и Unlock нет ни одного вызова функции, который мог бы запаниковать (чистые присваивания). Но это хрупкое предположение.
+
+## Связанные заметки
+
+- [[backend-rate-limiting]] — где находится проблемный код
+- [[MOC-backend-patterns]] — таблица антипаттернов
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `ratelimit.go:61`.
+
+## Теги
+
+#golang #concurrency #mutex #panic #best-practice #bug

+ 86 - 0
obsidian_data/Photoplaces_data/atomic-n-plus-one-getbyid.md

@@ -0,0 +1,86 @@
+# N+1 запросы в GetByID — три отдельных round-trip
+
+**Контекст**: В `backend/internal/services/places.go:112-136` метод `GetByID` делает 3 отдельных запроса к БД вместо одного JOIN. При массовом вызове (например, список избранного) это даёт N*3 запросов.
+
+## Проблема
+
+```go
+// services/places.go:112-136 — ❌ N+1
+func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
+    place, err := s.placeRepo.GetByID(ctx, id)          // 1-й запрос
+    if place == nil { return nil, nil }
+
+    if fetchTags {
+        tags, err := s.placeRepo.GetTags(ctx, id)       // 2-й запрос
+        features, err := s.placeRepo.GetFeatures(ctx, id) // 3-й запрос
+        place.Tags = tags
+        place.Features = features
+    }
+    return place, nil
+}
+```
+
+Для одного места — приемлемо (3 round-trips). Но если вызвать `GetByID` в цикле для 10 мест — уже 30 запросов.
+
+## Почему не JOIN
+
+1. **Разделение ответственности**: `GetTags` и `GetFeatures` — отдельные методы репозитория, переиспользуемые в `List` с `batch`
+2. **Сложность JOIN**: places → place_tags → tags — many-to-many, JOIN даёт дублирование строк
+3. **Редкий вызов**: `GetByID` вызывается только при запросе конкретного места, не в списках
+
+## Когда это станет проблемой
+
+- Когда появится функциональность "избранное" или "сравнение мест" — фронтенд будет запрашивать `GET /places/{id}` для каждого места
+- Когда к `GetByID` добавятся теги услуг, отзывов, бронирований — 3 превратится в 6+
+
+## Решение
+
+```go
+// ✅ один запрос с JOIN
+func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
+    place, err := s.placeRepo.GetByIDWithDetails(ctx, id)  // JOIN: place + tags + features
+    if place == nil { return nil, nil }
+    return place, nil
+}
+```
+
+```go
+// repository/places.go
+func (r *PlaceRepo) GetByIDWithDetails(ctx context.Context, id string) (*models.Place, error) {
+    query := `
+        SELECT p.*, 
+               COALESCE(json_agg(DISTINCT jsonb_build_object('id', t.id, 'name', t.name)) 
+                       FILTER (WHERE t.id IS NOT NULL), '[]') as tags,
+               COALESCE(json_agg(DISTINCT jsonb_build_object('id', f.id, 'name', f.name)) 
+                       FILTER (WHERE f.id IS NOT NULL), '[]') as features
+        FROM places p
+        LEFT JOIN place_tags pt ON pt.place_id = p.id
+        LEFT JOIN tags t ON t.id = pt.tag_id
+        LEFT JOIN place_features pf ON pf.place_id = p.id
+        LEFT JOIN features f ON f.id = pf.feature_id
+        WHERE p.id = $1
+        GROUP BY p.id
+    `
+    // ...
+}
+```
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|---|---|---|
+| **JOIN + json_agg** | 1 запрос, атомарно | Сложный SQL, GROUP BY может быть медленным |
+| **Три параллельных запроса** (goroutines) | 1 round-trip по времени | Сложнее, нужно управлять goroutines |
+| **Оставить как есть (3 последовательных)** | Просто | N+1 при массовом вызове |
+
+## Связанные заметки
+
+- [[MOC-backend-patterns]] — таблица антипаттернов
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `services/places.go:112-136`.
+
+## Теги
+
+#performance #database #golang #N+1 #anti-pattern

+ 101 - 0
obsidian_data/Photoplaces_data/atomic-redis-rate-limiter-failopen-failclosed.md

@@ -0,0 +1,101 @@
+# Fail-open vs Fail-closed в rate limiter: trade-off безопасности
+
+**Контекст**: В `backend/cmd/api/main.go:119` выбор между fail-open и fail-closed для Redis rate limiter определяется окружением: `failOpen := cfg.AppEnv != "production"`.
+
+## Суть
+
+```go
+// main.go:119
+failOpen := cfg.AppEnv != "production"
+```
+
+Когда Redis недоступен:
+- **Fail-open** (dev): запросы проходят без rate limiting. Сервис жив, но уязвим.
+- **Fail-closed** (production): rate limiter возвращает 503. Сервис недоступен, но защищён.
+
+```mermaid
+graph TD
+    A[Redis timeout / connection error] --> B{failOpen?}
+    B -->|Yes - dev| C[In-Memory fallback]
+    B -->|No - production| D[503 Service Unavailable]
+    C --> E[Rate limiting weaker]
+    D --> F[Service blocked]
+```
+
+## Trade-off
+
+### Fail-open (dev)
+
+```go
+// ratelimit_redis.go:61-64
+if r.failOpen {
+    next.ServeHTTP(w, req) // пропускаем запрос
+    return
+}
+```
+
+**Плюсы**:
+- Разработка не блокируется недоступностью Redis
+- Можно тестировать без Redis
+- Graceful degradation
+
+**Минусы**:
+- Без Redis rate limiting работает на in-memory (сбрасывается при рестарте)
+- В кластере каждый инстанс имеет свой счётчик
+- Потенциальный abuse при падении Redis
+
+### Fail-closed (production)
+
+```go
+// ratelimit_redis.go:66-69
+w.Header().Set("Content-Type", "application/json")
+w.WriteHeader(http.StatusServiceUnavailable)
+_, _ = w.Write([]byte(`{"error":"rate limiter unavailable"}`))
+```
+
+**Плюсы**:
+- Полная защита от abuse
+- Redis — SPoF не влияет на безопасность
+- Abuse-атака не может обойти rate limiter
+
+**Минусы**:
+- Redis — Single Point of Failure
+- При падении Redis весь API (кроме `/health`) недоступен
+- Нужен Redis Sentinel/Cluster для HA
+
+## Рекомендации для прода
+
+1. **Перейти на Redis Sentinel или Cluster** — устранить SPoF
+2. **Добавить alarm** при падении Redis (например, Prometheus + Alertmanager)
+3. **Рассмотреть грейсфул timeout**: 100ms timeout на Redis, после чего in-memory fallback, но с пониженными лимитами (например, 5 req/min вместо 60)
+4. **Вариант**: fallback на in-memory с **более строгими** лимитами в production (например, 10 req/min для всех эндпоинтов)
+
+```go
+// Пример: fallback со строгими лимитами
+if r.failOpen {
+    strictLimiter.Allow() // более строгий лимит для production fallback
+    next.ServeHTTP(w, req)
+    return
+}
+```
+
+## Что уже сделано
+
+- Redis health check при старте (`Ping` + `WarnContext`)
+- 100ms timeout на Redis запросы
+- Логирование при падении Redis
+- Выбор стратегии через `AppEnv`
+
+## Связанные заметки
+
+- [[backend-rate-limiting]] — общая архитектура rate limiting
+- [[decision-rate-limiter-redis]] — почему выбрали Redis
+- [[architecture-overview]] — таблица P2 (CSP)
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `main.go:119`.
+
+## Теги
+
+#redis #rate-limiting #production #availability #security #architecture

+ 80 - 0
obsidian_data/Photoplaces_data/atomic-toctou-race-registration.md

@@ -0,0 +1,80 @@
+# TOCTOU race condition при регистрации пользователя
+
+**Контекст**: В `backend/internal/services/auth.go:85-86`, метод `Register` сначала проверяет существование email, а потом создаёт пользователя. Между этими двумя операциями возможна гонка.
+
+## Проблема
+
+```go
+func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthResult, string, error) {
+    // Шаг 1: Check (Time-of-Check)
+    existing, _ := s.userRepo.GetByEmail(ctx, input.Email)  // ❌ ошибка подавлена
+    if existing != nil {
+        return nil, "", ErrEmailExists
+    }
+
+    // ⚡ В этот момент другой запрос может создать пользователя с тем же email
+
+    // Шаг 2: Act (Time-of-Use)
+    if err := s.userRepo.Create(ctx, user); err != nil {
+        return nil, "", fmt.Errorf("create user: %w", err)
+    }
+}
+```
+
+**Две проблемы**:
+1. **Подавление ошибки**: `_` игнорирует ошибку `GetByEmail`. Если БД недоступна — пользователь получит "email already exists" вместо ошибки сервера.
+2. **TOCTOU race**: два одновременных запроса с одинаковым email могут пройти проверку и оба создать пользователя.
+
+## Решение
+
+Использовать уникальный constraint на уровне БД + проверять ошибку `Create`:
+
+```sql
+-- миграция (уже есть)
+ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
+```
+
+```go
+func (s *AuthService) Register(ctx context.Context, input RegisterInput) (*AuthResult, string, error) {
+    // ... хеширование пароля, создание User ...
+
+    if err := s.userRepo.Create(ctx, user); err != nil {
+        // Проверяем, что ошибка — нарушение уникального constraint
+        if isDuplicateEmailError(err) {
+            return nil, "", ErrEmailExists
+        }
+        return nil, "", fmt.Errorf("create user: %w", err)
+    }
+
+    return s.generateTokens(ctx, user)
+}
+```
+
+```go
+// repository/users.go — проверка ошибки pgx
+func isDuplicateEmailError(err error) bool {
+    var pgErr *pgconn.PgError
+    return errors.As(err, &pgErr) && pgErr.Code == "23505" // unique_violation
+}
+```
+
+## Альтернативы
+
+| Подход | Плюсы | Минусы |
+|---|---|---|
+| **Unique constraint + проверка ошибки** | Атомарно, стандартная практика | Зависит от кода ошибки pgx |
+| **Транзакция + FOR UPDATE** | Можно сделать доп. проверки | Блокировка строки, медленнее |
+| **Optimistic locking** | Нет блокировок | Больше кода |
+
+## Связанные заметки
+
+- [[backend-auth-security]] — контекст auth сервиса
+- [[architecture-overview]] — таблица P0
+
+## Источник
+
+Code review PhotoPlaces 2026-06. `services/auth.go:85-86`.
+
+## Теги
+
+#concurrency #database #security #golang #race-condition #best-practice

+ 154 - 38
obsidian_data/Photoplaces_data/backend-auth-security.md

@@ -1,64 +1,180 @@
-# Backend: Безопасность JWT и Refresh Tokens
+# Backend: JWT и Refresh Tokens — Реализация и Best Practices
 
-**Контекст**: В `backend/internal/services/auth.go` реализована JWT-аутентификация с access (15 мин) и refresh (30 дней) токенами. Текущая реализация имеет критические уязвимости.
+**Контекст**: В `backend/internal/services/auth.go` реализована JWT-аутентификация с access (15 мин) и refresh (30 дней) токенами. Реализация включает серверное хранение refresh токенов, ротацию и детект повторного использования. **Обновлено после code review 2026-06 — код уже исправлен, заметка отражает текущее состояние.**
 
 ## Суть
 
-**Refresh токены не сохраняются в БД** — они валидируются только по криптографической подписи. Это значит:
-1. Нет возможности отозвать токен (logout, смена пароля, бан пользователя)
-2. Нет ротации refresh токенов (уязвимость к replay атакам)
-3. Утечка refresh токена = полный доступ на 30 дней
-4. Нельзя ограничить количество активных сессий пользователя
+Система аутентификации использует два типа токенов:
+1. **Access token** — JWT, 15 мин, передаётся в `Authorization: Bearer`, содержит `user_id` и `role`
+2. **Refresh token** — криптостойкая случайная строка (32 байта, base64), 30 дней, хранится в HttpOnly cookie
+
+## Архитектура
+
+```mermaid
+sequenceDiagram
+    participant C as Client
+    participant A as Go API
+    participant DB as PostgreSQL
+
+    C->>A: POST /auth/login { email, password }
+    A->>DB: SELECT user WHERE email
+    A->>A: bcrypt.CompareHashAndPassword
+    A->>DB: INSERT refresh_token (SHA-256 hash)
+    A-->>C: 200 { access_token, user }
+    Note over C: Set-Cookie: refresh_token (HttpOnly, Secure, SameSite)
+
+    C->>A: GET /auth/me (Authorization: Bearer access_token)
+    A->>A: JWT validation (signature + expiry)
+    A-->>C: 200 { user_id, role }
+
+    C->>A: POST /auth/refresh (Cookie: refresh_token)
+    A->>DB: SELECT refresh_token WHERE token_hash = SHA-256(cookie)
+    A->>DB: DELETE old token (if exists — rotation)
+    A->>DB: INSERT new refresh_token
+    A-->>C: 200 { new_access_token }
+    Note over C: Set-Cookie: new_refresh_token
+
+    alt Token Reuse Detected
+        C->>A: POST /auth/refresh (stale refresh_token)
+        A->>DB: SELECT — not found (already rotated)
+        A->>DB: SELECT revoked — found!
+        A->>DB: DELETE ALL tokens for user_id
+        A-->>C: 401 token reused, session revoked
+    end
+```
+
+## Реализация
 
-## Код (проблемное место)
+### Генерация токенов (`services/auth.go:152-182`)
 
 ```go
-// backend/internal/services/auth.go:152-164
-func (s *AuthService) ValidateRefreshToken(tokenString string) (string, error) {
-    token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
-        return s.refreshSecret, nil  // Только проверка подписи!
-    })
-    // ... нет проверки в БД, нет отзыва
+func (s *AuthService) generateTokens(ctx context.Context, user *models.User) (*AuthResult, string, error) {
+    accessToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{
+        UserID: user.ID,
+        Role:   user.Role,
+        RegisteredClaims: jwt.RegisteredClaims{
+            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
+        },
+    }).SignedString(s.jwtSecret)
+
+    refreshToken, _ := generateSecureToken(32) // crypto/rand, base64
+
+    // Хеш SHA-256 → сохраняем в БД
+    s.refreshTokenRepo.Create(ctx, user.ID, refreshToken, expiresAt)
+
+    return &AuthResult{User: user, AccessToken: accessToken}, refreshToken, nil
 }
 ```
 
-## Решение (Best Practice)
+### Ротация с защитой от race condition (`services/auth.go:198-249`)
+
+```go
+func (s *AuthService) RefreshSession(ctx context.Context, plainRefreshToken string) (*AuthResult, string, error) {
+    // 1. Ищем валидный токен в БД
+    storedToken, _ := s.refreshTokenRepo.GetValid(ctx, plainRefreshToken)
+    if storedToken == nil {
+        // 2. Не найден → проверяем, не был ли он отозван
+        revokedToken, _ := s.refreshTokenRepo.GetRevoked(ctx, plainRefreshToken)
+        if revokedToken != nil {
+            // 3. Token reuse! Отзываем ВСЕ сессии пользователя
+            s.refreshTokenRepo.RevokeAllForUser(ctx, revokedToken.UserID)
+            return nil, "", ErrTokenReused
+        }
+        return nil, "", ErrInvalidToken
+    }
+
+    // 4. Атомарная ротация: удаляем старый, создаём новый
+    deleted, _ := s.refreshTokenRepo.DeleteIfExists(ctx, storedToken.TokenHash)
+    if !deleted {
+        return nil, "", ErrTokenReused // concurrent rotation detected
+    }
+
+    return s.generateTokens(ctx, user)
+}
+```
+
+## Почему это правильно
+
+1. **Refresh token НЕ JWT** — это random string, хранящаяся в БД. Нельзя украсть подпись.
+2. **SHA-256 хеш в БД** — даже при утечке БД, refresh token не скомпрометирован.
+3. **Ротация при каждом refresh** — старый токен становится недействительным. Replay-атака невозможна.
+4. **`DeleteIfExists` проверяет `rows_affected`** — гарантирует, что только одна горутина выиграет race.
+5. **Token reuse → RevokeAllForUser** — при обнаружении повторного использования отзываются все сессии пользователя.
 
-Хранить хеш refresh токена в таблице `refresh_tokens` (уже есть в миграции `000002_create_users.up.sql`):
+## Использование cookie
 
-```sql
-CREATE TABLE refresh_tokens (
-    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-    user_id UUID NOT NULL REFERENCES users(id),
-    token_hash VARCHAR(255) NOT NULL,  -- SHA-256 от токена
-    expires_at TIMESTAMPTZ NOT NULL,
-    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
+```go
+// handlers/auth.go:153-168
+http.Cookie{
+    Name:     "refresh_token",
+    HttpOnly: true,
+    Secure:   h.isProd,
+    SameSite: http.SameSiteStrictMode, // CSRF защита
+    Path:     "/",
+    MaxAge:   int((30 * 24 * time.Hour).Seconds()),
+}
+```
+
+- `HttpOnly: true` — защита от XSS (JavaScript не прочитает cookie)
+- `Secure: true` — только по HTTPS (в production)
+- `SameSite: Strict` — защита от CSRF
+- `Path: "/"` — доступен на всех путях (включая `/auth/refresh`)
+
+## Middleware аутентификации
+
+```go
+// middleware/auth.go:23-48
+func AuthMiddleware(authSvc *services.AuthService) func(http.Handler) http.Handler {
+    return func(next http.Handler) http.Handler {
+        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+            authHeader := r.Header.Get("Authorization")
+            // Парсинг "Bearer <token>"
+            claims, err := authSvc.ValidateAccessToken(token)
+            if err != nil {
+                writeAuthError(w, "invalid or expired token")
+                return
+            }
+            // Встраиваем user_id и role в контекст
+            ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
+            ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
+            next.ServeHTTP(w, r.WithContext(ctx))
+        })
+    }
+}
 ```
 
-### Алгоритм:
-1. При логине: генерируем refresh token → сохраняем `SHA256(token)` в БД → отдаём пользователю plain token
-2. При refresh: хешируем полученный токен → ищем в БД → проверяем `expires_at` → **удаляем старый, создаём новый** (ротация)
-3. При logout / смене пароля / бане: удаляем все токены пользователя из БД
+## Ролевая модель
+
+| Роль | Разрешения |
+|---|---|
+| `superadmin` | Полный доступ |
+| `moderator` | Модерация мест, управление пользователями |
+| `landlord` | Создание/редактирование своих студий |
+| `executor` | Создание услуг |
+| `customer` | Создание мест |
+
+`RoleMiddleware(roles ...string)` проверяет роль из контекста.
 
-## Альтернативы
+## Альтернативы (для справки)
 
 | Подход | Плюсы | Минусы |
-|--------|-------|--------|
-| **БД + ротация (рекомендую)** | Полный контроль, отзыв, безопасность | Немного сложнее, лишний round-trip к БД |
-| **JWT blacklist в Redis** | Быстро, не трогает БД | Нужно хранить до expiry, нет ротации |
-| **Короткий access + долгий refresh без БД** | Просто | **Небезопасно** — текущее состояние |
+|---|---|---|
+| **Текущий: БД + ротация** | Полный контроль, отзыв, безопасность | +1 round-trip к БД |
+| **JWT blacklist в Redis** | Быстро, не трогает БД | Нужно хранить до expiry |
+| **Refresh token как JWT (без БД)** | Просто, 0 запросов к БД | **Нельзя отозвать** — утечка = полный доступ |
 
 ## Связанные заметки
 
-- [[decision-jwt-refresh-storage]]
-- [[backend-rate-limiting]]
-- [[architecture-overview]]
+- [[decision-jwt-refresh-storage]] — Decision record: почему БД
+- [[decision-validation-error-production]] — Безопасность ошибок
+- [[architecture-overview]] — Общий обзор
+- [[atomic-csrf-protection]] — SameSite cookie
+- [[atomic-refresh-token-race-condition]] — Race-free ротация
 
 ## Источник
 
-Задача: Code review PhotoPlaces — выявление критических уязвимостей безопасности
+Code review PhotoPlaces 2026-06. Актуализация после внедрения refresh token rotation в `services/auth.go`.
 
 ## Теги
 
-#backend #security #jwt #auth #critical #best-practice
+#backend #security #jwt #auth #best-practice

+ 153 - 48
obsidian_data/Photoplaces_data/backend-rate-limiting.md

@@ -1,79 +1,184 @@
-# Backend: Rate Limiting — In-Memory Проблема
+# Backend: Rate Limiting — Redis с graceful fallback
 
-**Контекст**: В `backend/internal/middleware/ratelimit.go` используется in-memory token bucket (golang.org/x/time/rate). В `main.go:54` создаётся глобальный лимитер на 60 req/min на IP.
+**Контекст**: В `backend/cmd/api/main.go` реализована гибридная стратегия rate limiting: Redis (sliding window через `ulule/limiter/v3`) как primary, in-memory token bucket (`golang.org/x/time/rate`) как fallback. **Обновлено 2026-06 — код использует Redis + in-memory fallback, а НЕ только in-memory, как было в первой версии заметки.**
 
 ## Суть
 
-**In-memory rate limiter не работает в production** при:
-- Масштабировании (multiple replicas) — каждый контейнер имеет свой счётчик
-- Рестарте контейнеров — счётчики сбрасываются
-- Загрузке через Load Balancer — IP клиента может попадать на разные инстансы
+Приложение использует два механизма rate limiting одновременно:
+1. **Redis** (`middleware/ratelimit_redis.go`) — sliding window алгоритм через `ulule/limiter/v3`, работает в кластере
+2. **In-memory** (`middleware/ratelimit.go`) — token bucket на `golang.org/x/time/rate`, fallback при недоступности Redis
+
+## Архитектура
+
+```mermaid
+graph TD
+    Request-->RateLimiter
+    RateLimiter-->Redis{Redis ping ok?}
+    Redis-->|Yes|RedisRL[Redis Sliding Window]
+    Redis-->|No|Decision{isProduction?}
+    Decision-->|Yes prod|FailClosed[503 Service Unavailable]
+    Decision-->|No dev|InMemory[In-Memory Token Bucket]
+    RedisRL-->|Rate Exceeded|Block[429 Too Many Requests]
+    InMemory-->|Rate Exceeded|Block
+    RedisRL-->|OK|Next[Next handler]
+    InMemory-->|OK|Next
+```
+
+## Реализация
+
+### Создание лимитера с fallback (`main.go:325-342`)
+
+```go
+func newRateLimiterWithFallback(
+    redisClient *redis.Client,
+    redisAvailable bool,
+    logger *slog.Logger,
+    failOpen bool,
+    redisLimiterFn func(*redis.Client, *slog.Logger, bool) (rateLimiterInterface, error),
+    inMemoryLimiterFn func() *middleware.RateLimiter,
+) rateLimiterInterface {
+    if redisAvailable {
+        if limiter, err := redisLimiterFn(redisClient, logger, failOpen); err == nil {
+            return limiter
+        }
+    }
+    return inMemoryLimiterFn()
+}
+```
 
-## Код (проблемное место)
+### Fail-open vs Fail-closed (`main.go:119`)
 
 ```go
-// backend/cmd/api/main.go:54
-rl := middleware.NewRateLimiter(rate.Limit(60), 60)  // In-memory!
-r.Use(rl.Middleware())
+failOpen := cfg.AppEnv != "production"
 ```
 
+- **Development** (`failOpen = true`): Redis недоступен → in-memory fallback. Сервис работает, но rate limiting сбрасывается при рестарте.
+- **Production** (`failOpen = false`): Redis недоступен → 503 Service Unavailable. Сервис недоступен, но защищён от abuse.
+
+### Redis rate limiter (`ratelimit_redis.go`)
+
 ```go
-// backend/internal/middleware/ratelimit.go (упрощённо)
-type RateLimiter struct {
-    limiter *rate.Limiter  // In-memory token bucket
+func NewRedisRateLimiter(redisClient *redis.Client, config RateLimitConfig) (*RedisRateLimiter, error) {
+    store, _ := limiterRedis.NewStoreWithOptions(redisClient, limiter.StoreOptions{
+        Prefix:   "ratelimit",
+        MaxRetry: 3,
+    })
+    instance := limiter.New(store, config.Rate)
+    return &RedisRateLimiter{
+        instance: instance,
+        keyFunc:  config.KeyFunc,
+        failOpen: config.FailOpen,
+    }, nil
 }
 ```
 
-## Решение: Redis-based Rate Limiting
+**Timeout**: 100ms на запрос к Redis. Если Redis отвечает дольше — считаем его недоступным.
+
+### In-memory rate limiter (`ratelimit.go`)
+
+```go
+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 := GetClientIP(r)
+            rl.mu.Lock()
+            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"}`, 429)
+                return
+            }
+            next.ServeHTTP(w, r)
+        })
+    }
+}
+```
 
-Использовать Redis с sliding window / token bucket алгоритмом. Ключ: `ratelimit:{ip}:{endpoint}` или `ratelimit:{user_id}:{endpoint}`.
+**⚠️ Известная проблема**: мьютекс не использует `defer` — при панике в `GetClientIP` будет deadlock. См. [[atomic-mutex-defer-panic]].
 
-### Библиотеки:
-- `github.com/ulule/limiter/v3` — поддерживает Redis, in-memory, sliding window
-- Или самописный на Lua-скрипте для атомарности
+## Настройки для разных эндпоинтов
+
+| Эндпоинты | Rate limit | Ключ | Реализация |
+|---|---|---|---|
+| `/auth/*` (register, login, refresh) | 10 req/min | IP (`auth:{ip}`) | `KeyByIP("auth")` |
+| `GET /places`, `/reviews`, `/tags`, `/services` | 60 req/min | UserID или IP (`api_read:{user_id}` или `api_read:ip:{ip}`) | `KeyByUserID("api_read")` |
+| `POST /places`, `/bookings`, `/reviews` | 10 req/min | UserID или IP | `KeyByUserID("api_write")` |
+| `/admin/*` (moderate, manage users, tags) | 100 req/min | UserID | `KeyByUserID("admin")` |
+
+### Почему `KeyByUserID` с fallback?
 
-### Пример с ulule/limiter:
 ```go
-import (
-    "github.com/redis/go-redis/v9"
-    "github.com/ulule/limiter/v3"
-    "github.com/ulule/limiter/v3/drivers/store/redis"
-)
-
-store := redis.NewStoreWithOptions(redisClient, limiter.StoreOptions{
-    Prefix: "ratelimit",
-})
-rate := limiter.Rate{Period: 1*time.Minute, Limit: 60}
-instance := limiter.New(store, rate)
+func KeyByUserID(prefix string) func(*http.Request) string {
+    return func(r *http.Request) string {
+        userID := GetUserID(r.Context())
+        if userID != "" {
+            return prefix + ":user:" + userID
+        }
+        return prefix + ":ip:" + GetClientIP(r) // fallback для неавторизованных
+    }
+}
 ```
 
-## Настройка для разных эндпоинтов (по API_CONTRACT.md)
+Авторизованные пользователи идентифицируются по `user_id` (стабильный ключ). Неавторизованные — по IP (менее стабильно, но защищает от анонимного abuse).
+
+## Заголовки ответа
+
+```go
+w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limit.Limit, 10))
+w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(limit.Remaining, 10))
+w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(limit.Reset, 10))
+```
+
+Клиент может отслеживать оставшиеся запросы через эти заголовки.
+
+## Как определяется IP клиента
+
+```go
+func GetClientIP(r *http.Request) string {
+    if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
+        parts := strings.Split(fwd, ",")
+        return strings.TrimSpace(parts[0]) // берём первый IP
+    }
+    if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
+        return realIP
+    }
+    // fallback: удаляем порт из RemoteAddr
+    addr := r.RemoteAddr
+    if idx := strings.LastIndex(addr, ":"); idx != -1 {
+        return addr[:idx]
+    }
+    return addr
+}
+```
 
-| Эндпоинт | Лимит | Ключ |
-|----------|-------|------|
-| `/auth/*` | 10/min | IP |
-| `GET /places` | 60/min | UserID или IP |
-| `POST /places` | 10/min | UserID |
-| `/admin/*` | 100/min | UserID |
+**Важно**: `X-Forwarded-For` доверяется, только если Caddy/nginx настроен на его установку. Иначе клиент может подделать IP.
 
-## Альтернативы
+## Проверки в production
 
-| Подход | Плюсы | Минусы |
-|--------|-------|--------|
-| **Redis + ulule/limiter (рекомендую)** | Работает в кластере, гибкие правила | Зависимость от Redis |
-| **nginx rate limiting** | На уровне инфраструктуры | Меньше гибкости, сложнее per-user |
-| **Cloudflare / API Gateway** | Не трогает код | Vendor lock-in, платное |
+- [ ] Redis работает в кластерном режиме (Sentinel/Cluster)?
+- [ ] `failOpen = false` для production?
+- [ ] Timeout Redis 100ms не слишком мал для геораспределённой infra?
+- [ ] Есть ли мониторинг/alert при падении Redis?
+- [ ] Rate limit ключи не пересекаются (auth vs api_read vs admin)?
+- [ ] `X-Forwarded-For` устанавливается только trusted proxy (Caddy/nginx)?
 
 ## Связанные заметки
 
-- [[decision-rate-limiter-redis]]
-- [[architecture-overview]]
-- [[deploy-production-readiness]]
+- [[decision-rate-limiter-redis]] — Decision record: Redis выбор
+- [[architecture-overview]] — Общий обзор
+- [[atomic-redis-rate-limiter-failopen-failclosed]] — Fail-open/closed стратегия
+- [[deploy-production-readiness]] — Production готовность
 
 ## Источник
 
-Задача: Code review PhotoPlaces — rate limiter не работает в проде
+Code review PhotoPlaces 2026-06. Актуализация — Redis + in-memory гибрид уже реализован.
 
 ## Теги
 
-#backend #rate-limiting #redis #scaling #critical #best-practice
+#backend #rate-limiting #redis #scaling #best-practice

+ 123 - 94
obsidian_data/Photoplaces_data/backend-validation.md

@@ -1,142 +1,171 @@
-# Backend: Валидация входящих данных
+# Backend: Валидация — go-playground/validator + кастомные правила
 
-**Контекст**: Во всех handlers (`places.go`, `services.go`, `auth.go` и др.) валидация делается вручную через `if req.Field == ""` проверки. Нет единой схемы валидации, нет структурированных ошибок.
+**Контекст**: Во всех хендлерах (`places.go`, `auth.go`, `services.go` и др.) валидация входящих данных выполняется через `go-playground/validator` с struct tags и кастомными валидаторами. **Обновлено 2026-06 — код уже использует go-playground/validator, а не ручную валидацию, как было указано в первой версии заметки.**
 
 ## Суть
 
-**Ручная валидация — не масштабируема и ошибочна**:
-- Дублирование кода проверок
-- Неконсистентные сообщения об ошибках
-- Нет валидации типов, форматов (email, URL, UUID, координаты)
-- Сложно поддерживать и тестировать
-
-## Код (проблемное место)
-
-```go
-// backend/internal/handlers/places.go:120-147
-func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
-    var req createPlaceRequest
-    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-        writeError(w, http.StatusBadRequest, "invalid request body")
-        return
-    }
-
-    if req.Title == "" {           // Ручная проверка
-        writeError(w, http.StatusBadRequest, "title is required")
-        return
-    }
-    if req.Type == "" {            // Ручная проверка
-        writeError(w, http.StatusBadRequest, "type is required")
-        return
-    }
-    // ... нет валидации: lat/lng range, URL формат, enum для type, длина строк
-}
-```
-
-## Решение: go-playground/validator v10
-
-Стандарт де-факто в Go экосистеме. Поддерживает:
+В проекте реализован единый слой валидации в `backend/internal/validator/validator.go`:
 - Struct tags: `validate:"required,email,max=255"`
-- Custom validators
-- Перевод ошибок
-- Вложенные структуры
-
-### Пример внедрения:
+- Кастомные валидаторы: `uuid`, `slug`, `latitude`, `longitude`, `place_type`, `user_role`, `currency`, `datetime` и др.
+- Функции: `Validate(s interface{})` и `ValidateVar(field interface{}, tag string)`
+- Алиас: `ValidationErrors = validator.ValidationErrors`
 
-```go
-// internal/validator/validator.go
-package validator
+## Реализация
 
-import (
-    "github.com/go-playground/validator/v10"
-)
+### Пакет валидатора (`validator/validator.go`)
 
-var Validate *validator.Validate
+```go
+var validate *validator.Validate
 
 func init() {
-    Validate = validator.New()
-    // Регистрация кастомных валидаторов
-    Validate.RegisterValidation("coordinate", validateCoordinate)
+    validate = validator.New()
+
+    _ = validate.RegisterValidation("uuid", validateUUID)
+    _ = validate.RegisterValidation("slug", validateSlug)
+    _ = validate.RegisterValidation("latitude", validateLatitude)
+    _ = validate.RegisterValidation("longitude", validateLongitude)
+    _ = validate.RegisterValidation("place_type", validatePlaceType)
+    _ = validate.RegisterValidation("user_role", validateUserRole)
+    _ = validate.RegisterValidation("currency", validateCurrency)
+    _ = validate.RegisterValidation("user_status", validateUserStatus)
+    _ = validate.RegisterValidation("place_status", validatePlaceStatus)
+    _ = validate.RegisterValidation("service_status", validateServiceStatus)
+    _ = validate.RegisterValidation("booking_status", validateBookingStatus)
+    _ = validate.RegisterValidation("datetime", validateDateTime)
 }
 
-func validateCoordinate(fl validator.FieldLevel) bool {
-    lat := fl.Field().Float()
-    return lat >= -90 && lat <= 90
-}
+func Validate(s interface{}) error { return validate.Struct(s) }
+func ValidateVar(field interface{}, tag string) error { return validate.Var(field, tag) }
 ```
 
+### Кастомные валидаторы
+
+| Валидатор | Формат | Пример |
+|---|---|---|
+| `uuid` | `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` | `550e8400-e29b-41d4-a716-446655440000` |
+| `slug` | `^[a-z0-9-]+$` | `portrait-photography` |
+| `latitude` | `>= -90 && <= 90` | `55.7558` |
+| `longitude` | `>= -180 && <= 180` | `37.6173` |
+| `place_type` | `place` или `studio` |
+| `user_role` | `customer`, `landlord`, `executor`, `moderator`, `superadmin` |
+| `currency` | `^[A-Z]{3}$` | `RUB`, `USD` |
+| `datetime` | `time.Parse(time.RFC3339)` | `2026-06-24T12:00:00Z` |
+
+### Использование в хендлерах
+
 ```go
-// internal/handlers/places.go — DTO с тегами
+// handlers/places.go — DTO с тегами валидации
 type createPlaceRequest struct {
     Title       string   `json:"title" validate:"required,min=1,max=255"`
     Description *string  `json:"description" validate:"omitempty,max=5000"`
     Address     *string  `json:"address" validate:"omitempty,max=500"`
-    Lat         float64  `json:"lat" validate:"required,coordinate,min=-90,max=90"`
-    Lng         float64  `json:"lng" validate:"required,coordinate,min=-180,max=180"`
-    Type        string   `json:"type" validate:"required,oneof=place studio"`
-    Tags        []string `json:"tags" validate:"dive,required,alphanum"`
-    Features    []string `json:"features" validate:"dive,required,alphanum"`
+    Lat         float64  `json:"lat" validate:"required,latitude"`
+    Lng         float64  `json:"lng" validate:"required,longitude"`
+    Type        string   `json:"type" validate:"required,place_type"`
+    AccessInfo  *string  `json:"access_info" validate:"omitempty,max=2000"`
+    CoverImage  *string  `json:"cover_image" validate:"omitempty,url,max=500"`
+    Tags        []string `json:"tags" validate:"dive,slug,max=50"`
+    Features    []string `json:"features" validate:"dive,slug,max=50"`
     HourlyRate  *int     `json:"hourly_rate" validate:"omitempty,min=0"`
-    Currency    string   `json:"currency" validate:"omitempty,len=3,uppercase"`
-    MinHours    int      `json:"min_hours" validate:"min=0"`
+    Currency    string   `json:"currency" validate:"omitempty,currency,len=3"`
+    MinHours    int      `json:"min_hours" validate:"min=0,max=100"`
 }
-```
 
-```go
-// В handler:
 func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
     var req createPlaceRequest
     if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-        writeError(w, http.StatusBadRequest, "invalid request body")
+        writeError(w, http.StatusBadRequest, "invalid request body", err)
         return
     }
-
-    if err := validator.Validate.Struct(req); err != nil {
-        var ve validator.ValidationErrors
-        if errors.As(err, &ve) {
-            writeValidationError(w, ve)  // Структурированный ответ
-            return
-        }
-        writeError(w, http.StatusBadRequest, err.Error())
+    if err := validator.Validate(req); err != nil {
+        writeValidationError(w, err) // 422 Unprocessable Entity
         return
     }
-    // ...
+    // ... бизнес-логика
 }
 ```
 
-## Структурированный ответ об ошибках (RFC 7807 / Problem Details)
+## Обработка ошибок валидации
+
+### `writeValidationError` (`handlers/errors.go:39-48`)
+
+```go
+func writeValidationError(w http.ResponseWriter, err error) {
+    details := err.Error()
+    if AppEnv == "production" {
+        details = "validation failed" // не раскрываем детали в production
+    }
+    writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
+        "error":   "validation failed",
+        "details": details,
+    })
+}
+```
 
+**Ответ**:
 ```json
 {
-  "type": "https://api.photoplaces.ru/errors/validation-error",
-  "title": "Validation Failed",
-  "status": 422,
-  "detail": "One or more fields failed validation",
-  "errors": [
-    { "field": "title", "message": "title is required" },
-    { "field": "lat", "message": "lat must be between -90 and 90" }
-  ]
+  "error": "validation failed",
+  "details": "Key: 'createPlaceRequest.Title' Error:Field validation for 'Title' failed on the 'required' tag"
 }
 ```
 
-## Альтернативы
+В production `details` заменяется на `"validation failed"` — защита от утечки структуры данных.
+
+### `validationMessage` (`handlers/auth.go:195-221`)
+
+Отдельная функция для маппинга validation tag → человекочитаемое сообщение:
+
+```go
+func validationMessage(tag, param string) string {
+    switch tag {
+    case "required": return "field is required"
+    case "email":    return "invalid email format"
+    case "latitude": return "latitude must be between -90 and 90"
+    // ...
+    }
+}
+```
+
+**⚠️ Недостаток**: это дублирование логики `go-playground/validator`. Лучше использовать `RegisterTranslation()` вместо ручного switch. См. раздел "Улучшения".
+
+## Улучшения (Todo)
+
+1. **Регистрация переводов** — вместо `validationMessage` использовать `RegisterTranslation()`:
+   ```go
+   validate.RegisterTranslation("required", trans, 
+       func(ut ut.Translator) error { return ut.Add("required", "{0} обязателен", true) },
+       func(ut ut.Translator, fe validator.FieldError) string { t, _ := ut.T("required", fe.Field()); return t },
+   )
+   ```
+
+2. **Структурированный ответ (RFC 7807)**:
+   ```json
+   {
+     "type": "https://api.photoplaces.dev/errors/validation",
+     "title": "Validation Failed",
+     "status": 422,
+     "errors": [
+       { "field": "email", "message": "invalid email format" }
+     ]
+   }
+   ```
+
+3. **Cross-field validation** — `gtfield`, `ltefield` для зависимых полей (например, `start_time < end_time`)
 
-| Библиотека | Плюсы | Минусы |
-|------------|-------|--------|
-| **go-playground/validator (рекомендую)** | Стандарт, быстрый, теги, кастомные правила | Рефлексия (небольшой оверхед) |
-| **go-ozzo/ozzo-validation** | Функциональный стиль, без рефлексии | Менее популярный |
-| **Ручная (текущее)** | Нет зависимостей | Неподдерживаемо, баги |
+4. **Пайплайны** — `RegisterStructCtx` для кастомной логики после базовой валидации
 
 ## Связанные заметки
 
-- [[decision-validation-library]]
-- [[backend-auth-security]]
-- [[architecture-overview]]
+- [[decision-validation-library]] — Decision record: почему go-playground/validator
+- [[decision-validation-error-production]] — Скрытие деталей ошибок
+- [[architecture-overview]] — Общий обзор
+- [[atomic-global-appenv-package-var]] — Проблема глобальной `AppEnv`
 
 ## Источник
 
-Задача: Code review PhotoPlaces — отсутствие валидации входных данных
+Code review PhotoPlaces 2026-06. Актуализация — go-playground/validator уже внедрён во все хендлеры.
 
 ## Теги
 
-#backend #validation #go #best-practice #technical-debt
+#backend #validation #go #best-practice

+ 67 - 89
obsidian_data/Photoplaces_data/deploy-production-readiness.md

@@ -1,21 +1,53 @@
 # Deploy: Production Readiness Checklist
 
-**Контекст**: В `deploy/` есть `docker-compose.prod.yml`, `deploy.sh`, `env.prod`. Локально — `docker-compose.yml` + `Makefile`.
+**Контекст**: В `deploy/` есть `docker-compose.prod.yml`, `deploy.sh`, `env.prod`. Локально — `docker-compose.yml` + `Makefile`. **Обновлено 2026-06 — добавлен Caddy reverse proxy, но есть ещё проблемы.**
 
 ## Суть
 
-**Проект разворачивается, но не production-ready** — есть риски безопасности, надежности и операционности.
+Проект разворачивается, Caddy настроен как reverse proxy с health headers и безопасными заголовками. Однако есть риски безопасности, надёжности и операционности.
 
 ## Критические проблемы (P0)
 
 | Проблема | Файл | Риск |
-|----------|------|------|
+|---|---|---|
 | **Secrets в plain text** | `env.prod`, `docker-compose.yml` | Утечка БД, JWT, S3 при коммите / доступе к серверу |
-| **Нет healthcheck у backend/frontend в prod** | `docker-compose.prod.yml` | Контейнеры могут быть "up" но не готовы к трафику |
-| **Миграции не гарантированно накатываются** | `docker-compose.prod.yml:52` | `migrate/migrate` запускается как sidecar, нет retry логики |
-| **Нет reverse proxy (nginx/traefik)** | `docker-compose.prod.yml` | Прямой доступ к портам 8080/3000, нет SSL, нет rate limiting на входе |
+| **Миграции не гарантированно накатываются** | `docker-compose.prod.yml:52` | `migrate/migrate` sidecar без retry логики |
 | **Single instance БД/Redis/MinIO** | `docker-compose.prod.yml` | SPOF, нет HA, нет бэкапов |
-| **Hardcoded IP в env.prod** | `env.prod:13,22,27` | Не переносимо, не работает за доменом |
+| **Hardcoded IP в env.prod** | `env.prod` | Не переносимо, не работает за доменом |
+| **CSP с `unsafe-inline` и `unsafe-eval`** | `Caddyfile:40` | XSS-защита ослаблена (см. [[atomic-csp-hardening]]) |
+| **Redis — SPOF для rate limiter** | fail-closed в production (503 при падении) | [[atomic-redis-rate-limiter-failopen-failclosed]] |
+
+## Текущая конфигурация Caddy
+
+```caddyfile
+# deploy/Caddyfile — уже работает как reverse proxy
+http://api.{$DOMAIN} {
+    reverse_proxy backend:8080
+    header { ... }
+}
+
+http://{$DOMAIN} {
+    reverse_proxy frontend:3000
+    header {
+        Content-Security-Policy "..."
+        X-Content-Type-Options "nosniff"
+        X-Frame-Options "DENY"
+    }
+    encode zstd gzip
+}
+```
+
+**Что уже хорошо**:
+- ✅ Reverse Proxy для frontend + backend
+- ✅ X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy
+- ✅ Content-Security-Policy (хотя и с `unsafe-inline`)
+- ✅ Cache-Control для статики
+- ✅ zstd + gzip encoding
+
+**Что нужно исправить**:
+- ❌ Используется `http://`, а не HTTPS (Caddy умеет авто-HTTPS через Let's Encrypt)
+- ❌ CSP с `unsafe-inline` и `unsafe-eval` (см. [[atomic-csp-hardening]])
+- ❌ Нет HSTS заголовка
 
 ## Рекомендуемые изменения
 
@@ -28,24 +60,9 @@
 # - .env файл только на сервере (chmod 600), в .gitignore
 ```
 
-### 2. Reverse Proxy (Nginx/Traefik/Caddy)
-```yaml
-# docker-compose.prod.yml — добавить сервис
-  proxy:
-    image: caddy:2-alpine
-    ports:
-      - "80:80"
-      - "443:443"
-    volumes:
-      - ./Caddyfile:/etc/caddy/Caddyfile
-      - caddy_data:/data
-      - caddy_config:/config
-    depends_on:
-      - backend
-      - frontend
-```
+### 2. HTTPS (убрать `http://` из Caddyfile)
 ```caddyfile
-# Caddyfile — автоматический HTTPS через Let's Encrypt
+# Caddyfile — авто-HTTPS через Let's Encrypt
 photoplaces.ru {
     reverse_proxy frontend:3000
 }
@@ -55,9 +72,10 @@ api.photoplaces.ru {
 }
 ```
 
+Caddy автоматически получит сертификаты для перечисленных доменов.
+
 ### 3. Healthchecks для всех сервисов
 ```yaml
-# backend healthcheck
 backend:
     healthcheck:
       test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
@@ -65,99 +83,59 @@ backend:
       timeout: 10s
       retries: 3
       start_period: 10s
-
-# frontend healthcheck
-frontend:
-    healthcheck:
-      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
-      interval: 30s
-      timeout: 10s
-      retries: 3
 ```
 
 ### 4. Database Backups
 ```yaml
-# Добавить сервис бэкапа
-  postgres-backup:
+postgres-backup:
     image: prodrigestivill/postgres-backup-local
     environment:
-      POSTGRES_HOST: postgres
-      POSTGRES_DB: photoplaces
-      POSTGRES_USER: photoplaces
-      POSTGRES_PASSWORD: ${DB_PASSWORD}
       SCHEDULE: "@daily"
       BACKUP_KEEP_DAYS: 30
-    volumes:
-      - ./backups:/backups
 ```
 
 ### 5. Логирование и мониторинг
-```yaml
-# Добавить Loki + Promtail или ELK
-  loki:
-    image: grafana/loki:2.9
-    ports: ["3100:3100"]
-  
-  promtail:
-    image: grafana/promtail:2.9
-    volumes:
-      - /var/log:/var/log
-      - ./promtail.yml:/etc/promtail.yml
-```
-
-### 6. Упрощённый деплой (одна команда)
-```bash
-# deploy/deploy.sh — улучшенная версия
-#!/usr/bin/env bash
-set -euo pipefail
+- Loki + Promtail для логов
+- Prometheus + Grafana для метрик
+- Настроить алерты: падение Redis >5min, error rate >1%, p99 latency >500ms
 
-ENV_FILE="${1:-deploy/env.prod}"
-[ -f "$ENV_FILE" ] || { echo "Create $ENV_FILE from example"; exit 1; }
-
-# 1. Load secrets (support 1Password, Vault, etc.)
-source "$ENV_FILE"
-
-# 2. Build images with tags
-VERSION=$(git describe --tags --always --dirty)
-docker compose -f deploy/docker-compose.prod.yml build --build-arg VERSION="$VERSION"
-
-# 3. Run migrations (with retry)
-docker compose -f deploy/docker-compose.prod.yml run --rm migrations
-
-# 4. Deploy with zero-downtime (rolling update)
-docker compose -f deploy/docker-compose.prod.yml up -d --remove-orphans
-
-# 5. Health checks
-sleep 10
-curl -sf http://localhost/health || { echo "Health check failed"; exit 1; }
-
-echo "✓ Deployed version $VERSION"
-```
+### 6. Healthcheck зависимость (fix)
+В текущем `docker-compose.yml` есть проблема: backend healthcheck использует `curl` к самому себе на `/api/v1/health`, но этот эндпоинт доступен только после старта Go приложения. Это может создать циклическое ожидание с `depends_on` из frontend. Рекомендуется:
+- Добавить простой healthcheck для backend (TCP check на порт 8080)
+- Или сделать `/health` на уровне Caddy/Router перед Go-обработчиками
 
 ## Чек-лист перед продакшеном
 
 - [ ] Все секреты вынесены из репозитория
-- [ ] Настроен HTTPS (Let's Encrypt / Caddy / Traefik)
-- [ ] Healthchecks на всех сервисах
+- [ ] Настроен HTTPS (Let's Encrypt / Caddy авто-HTTPS)
+- [ ] Убрать `http://` из Caddyfile — перейти на домены
+- [ ] Healthchecks на всех сервисах (working!)
+- [ ] Redis Sentinel/Cluster для HA (чтобы не было SPOF)
 - [ ] Автоматические бэкапы БД (проверено restore)
 - [ ] Логи агрегируются (Loki/ELK)
 - [ ] Метрики собираются (Prometheus + Grafana)
 - [ ] Настроены алерты (down, high error rate, high latency)
-- [ ] Rate limiting на уровне прокси + приложение
+- [ ] CSP hardening: nonce вместо `unsafe-inline`, strict-dynamic ([[atomic-csp-hardening]])
+- [ ] Rate limiting: fail-open/closed стратегия ([[atomic-redis-rate-limiter-failopen-failclosed]])
 - [ ] CORS настроен только для продакшен доменов
 - [ ] Security headers (CSP, HSTS, X-Frame-Options)
 - [ ] Нагрузочное тестирование пройдено
+- [ ] Graceful shutdown проверен (освобождение портов, закрытие соединений)
+- [ ] Падение Redis: что происходит с rate limiter (должен быть alarm + fallback)
 
 ## Связанные заметки
 
-- [[backend-rate-limiting]] — Rate limiter нужен Redis для прод
-- [[architecture-overview]]
-- [[decision-reverse-proxy]]
+- [[backend-rate-limiting]] — rate limiter с fail-closed в production
+- [[atomic-csp-hardening]] — CSP hardening
+- [[atomic-redis-rate-limiter-failopen-failclosed]] — Fail-open/closed стратегия
+- [[architecture-overview]] — общий обзор и таблица P0-P2
+- [[MOC-security-patterns]] — карта безопасности
+- [[decision-validation-error-production]] — скрытие ошибок
 
 ## Источник
 
-Задача: Code review PhotoPlaces — production readiness
+Code review PhotoPlaces 2026-06. Проверка production readiness + обновление после внедрения Caddy.
 
 ## Теги
 
-#deploy #docker #production #security #monitoring #best-practice
+#deploy #docker #production #security #monitoring #best-practice