# Findings: Аудит проекта PhotoPlaces Дата аудита: 2026-06-13 Дата исправлений: 2026-06-14 --- ## Статус исправлений ### ✅ P0 — Исправлено 1. **WebSocket CheckOrigin** — больше не `return true`. При пустом `allowedOrigins` в production соединения отклоняются. 2. **Sentinel error для "not your place"** — заменён на `ErrNotYourPlace` с `errors.Is`-поддержкой. 3. **Dev-секреты в docker-compose.yml** — вынесены в переменные `${VAR:-default}`. 4. **env.prod** — очищен от замусоренных placeholder-ключей. 5. **Docker secrets** — config.go поддерживает `_FILE` suffix. 6. **JWT секреты без дефолтов в production** — config.go требует JWT_SECRET, JWT_REFRESH_SECRET, ALLOWED_ORIGINS в production; дефолты ("dev-secret", "localhost:3000") запрещены, приложение паникует при старте. 7. **Token Reuse Detection** — уже реализован: `GetRevoked` в репозитории, проверка в `RefreshSession`, отзыв всех токенов пользователя при обнаружении, обработка в хендлере. 8. **Скрытие внутренних ошибок в production** — все хендлеры используют единый `writeError` с параметром `err` для логирования; в production 500 ошибки возвращают "internal server error" вместо деталей БД/логики. 9. **WebSocket heartbeat + pub/sub** — рефакторинг бэкенда и интеграция во фронтенд: - **Backend**: Hub с `register/unregister/broadcast`, `Run()` горутина; `Client` с `send chan`, `readPump`/`writePump` горутинами; ping/pong heartbeat (60s pong wait, 54s ping interval); typed events (`{"type":"visitors","data":{...}}`). - **Frontend**: хук `useWebSocket` с auto-reconnect (exponential backoff 1→30s); `MapView` заменяет polling 60s на WS для отображения точек других посетителей. - Breaking change: старый формат `{"visitors":[...]}` не поддерживается. 10. **Ценовой race condition в бронированиях** — `SELECT ... FOR UPDATE` на `places` внутри транзакции в `BookingRepo.Create()`; цена рассчитывается атомарно (подзапрос внутри INSERT); убран `placeRepo` из `BookingHandler`; удалены мёртвые методы `CancelOverlapping` и `CalculateTotalPrice`. ### ✅ P1 — Исправлено 11. **Теги и фичи в листинге мест** — `PlaceFilter.IncludeTagsFeatures` + batch-загрузка (`GetTagsBatch`/`GetFeaturesBatch`), запрос `?include=tags,features`. N+1 проблема решена. 12. **Cursor-based пагинация** — `PlaceFilter.CursorRating`, `PaginatedPlaces` с json-тегами, сервис возвращает `*PaginatedPlaces`, репозиторий поддерживает курсор для `created_at` и `rating` сортировок. API: `?cursor=&limit=N`. Ответ: `{"data":[...], "next_cursor":"...", "has_more":true}`. 13. **Интеграционные тесты** — репозитории: `TestMain` с подключением по `DATABASE_URL_TEST`, фикстуры (user, place, tag), автономные тесты с `cleanTables`. Booking: цена, overlap, place not found, same slot different place. Place: status filter, cursor pagination, batch tags. ### ✅ P1 — Исправлено 6. **Rate limiter** — Redis-based имплементация подключена в main.go, in-memory как fallback при недоступности Redis (реализован корректный fallback: при недоступности Redis автоматически используется in-memory limiter; в production `failOpen=false` — возвращается 503 только если оба лимитера недоступны). 7. **`string(rune(line))`** — код в `log.go` уже использует `strconv.Itoa`. Баг был исправлен до аудита. 8. **`strPtr` дублирование** — удалён из `handlers/helpers.go` и `services/auth.go`. Создан единый `pointer` package. 9. **`contains`/`searchString`** — код в `repository/reviews.go` использует `errors.As` напрямую. Проблема не актуальна. ### ✅ Тесты — Добавлены 10. **Backend unit-тесты:** - `services/auth_test.go` — 6 тестов (регистрация, дубликат email, неверный пароль, бан, валидация токена, невалидный токен) - `services/places_test.go` — 3 теста (создание, не-владелец, неизвестный action модерации) 11. **Frontend тесты:** - `vitest` + `@testing-library/react` - `ErrorBoundary.test.tsx` — 2 теста ### ✅ Frontend — Исправлено 12. **ErrorBoundary** — создан компонент, подключён в layout.tsx 13. **`catch {}`** — в admin/tags/page.tsx заменён на обработку с сообщением пользователю 14. **`console.error`** — в MapView.tsx заменён на тихий catch (карта работает с текущими данными) ### ✅ Архитектура — Улучшено 15. **Dependency Inversion** — в сервисы добавлены интерфейсы `PlaceRepo`, `UserRepo`, `RefreshTokenRepo` для тестируемости 16. **CSP** — в Caddyfile добавлен Content-Security-Policy 17. **WebSocket** — добавлен `isProd` флаг для безопасного fallback origins 18. **docker-compose.yml** — все пароли через переменные окружения ### ⏳ P2 — Отложено 19. **MapLibre/OSM** — поддержка альтернативных карт. Задача на V2. 20. **Тесты frontend** — база настроена (vitest), нужно расширять покрытие. 21. **CI/CD** — `.github/dependabot.yml` есть, но нет workflow. Нужен GitHub Actions. --- ## Сводка изменений | Файл | Изменение | |------|-----------| | `backend/internal/services/places.go` | Sentinel errors + интерфейс PlaceRepo | | `backend/internal/services/auth.go` | Интерфейсы UserRepo/RefreshTokenRepo, убран strPtr | | `backend/internal/handlers/websocket.go` | isProd + CheckOrigin без fallback true | | `backend/internal/handlers/helpers.go` | Удалён strPtr | | `backend/internal/handlers/tags.go` | strPtr → pointer.Str | | `backend/internal/pointer/` | Новый пакет (Str, Int, Float64) | | `backend/cmd/api/main.go` | isProd для WS Hub; rate limiter fallback (Redis → in-memory) | | `backend/internal/config/config.go` | JWT секреты обязательны в production, запрещены дефолты | | `backend/internal/handlers/errors.go` | **Новый**: единый writeError с логированием и скрытием деталей в prod | | `backend/internal/handlers/ws_client.go` | **Новый**: Client readPump/writePump, ping/pong heartbeat | | `backend/internal/handlers/websocket.go` | **Переписан**: Hub с register/unregister/broadcast, Run(), typed events | | `backend/internal/handlers/*.go` | Все хендлеры обновлены: writeError(msg, err) вместо err.Error() | | `backend/internal/models/place.go` | PlaceFilter.IncludeTagsFeatures, CursorRating; PaginatedPlaces json-теги | | `backend/internal/services/places.go` | List: batch-загрузка тегов/фич, encodeCursor/DecodeCursor, возвращает *PaginatedPlaces | | `backend/internal/repository/places.go` | GetTagsBatch, GetFeaturesBatch; курсор для rating sort | | `backend/internal/handlers/places.go` | List: парсинг ?include=tags,features и ?cursor= | | `backend/internal/repository/bookings.go` | Create: транзакция + SELECT ... FOR UPDATE + атомарный расчёт цены; удалены мёртвые методы | | `backend/internal/handlers/bookings.go` | Убран placeRepo; ErrPlaceNotFound в хендлере; цена в репозитории | | `backend/internal/repository/integration_test.go` | **Новый**: TestMain, тестовые фикстуры (insertTestUser, insertTestPlace, insertTestTag, cleanTables) | | `backend/internal/repository/booking_repo_test.go` | **Новый**: booking create (price, overlap, place not found, same slot different place) | | `backend/internal/repository/place_repo_test.go` | **Новый**: list with filters, cursor pagination, GetTagsBatch | | `docker-compose.yml` | Все пароли через ${VAR:-default} | | `deploy/env.prod` | Очищены placeholder-ключи | | `deploy/Caddyfile` | CSP + Permissions-Policy | | `frontend/src/components/ErrorBoundary.tsx` | Новый компонент | | `frontend/src/app/layout.tsx` | ErrorBoundary обёртка | | `frontend/src/app/admin/tags/page.tsx` | Обработка ошибок | | `frontend/src/components/MapView.tsx` | console.error → тихий catch; polling → WebSocket visitor dots | | `frontend/src/hooks/useWebSocket.ts` | **Новый**: хук с auto-reconnect, отправкой позиции, приёмом списка посетителей | | `frontend/vitest.config.ts` | Новый файл | | `frontend/src/test/` | setup.ts, ErrorBoundary.test.tsx | | `obsidian_data/Photoplaces_data/` | 7 новых Zettelkasten-заметок | --- ## Исправления от 2026-06-24 (аудит neyrogovnarik) ### P0 — Критические исправления | # | Проблема | Решение | |---|----------|---------| | 1 | **Тесты не компилируются** — `mockPlaceRepo.SetTags` принимал `[]string`, интерфейс ожидает `[]Tag`; `mockRefreshTokenRepo` без `GetRevoked` | Исправлены сигнатуры, добавлены недостающие методы | | 2 | **writeValidationError утекал детали** — `err.Error()` всегда возвращался в production | В production: `details: "validation failed"` | | 3 | **SameSite=LaxMode** — refresh token уязвим к CSRF | В production: `SameSite=StrictMode` | | 4 | **WebSocket без auth и rate limit** — анонимные `visitor_update` без ограничений | Добавлен per-connection rate limiter (10/s burst 1), глобальный лимит 1000 соединений; логин из JWT | | 5 | **Race condition refresh** — конкурентные refresh сбрасывают все сессии | `DeleteIfExists` с проверкой `RowsAffected`; `ErrTokenReused` без `RevokeAll` | | 6 | **In-memory rate limiter использует RemoteAddr** — за Caddy все IP одинаковы | Переключён на `GetClientIP()` (читает `X-Forwarded-For`) | ### P1 — Исправлено | # | Проблема | Решение | |---|----------|---------| | 7 | **appEnv не используется** в 6 хендлерах | Параметр удалён из конструкторов | | 8 | **Расчёт цены брони** — `int()` урезал дробные часы | `math.Ceil()` | | 9 | **Нет проверки min_hours** в бронированиях | `GetPlaceMinHours`, валидация в `Create` | | 10 | **Race condition cancel брони** | `UpdateStatusIfPending` — `WHERE status='pending'` | | 11 | **Refresh token cleanup не вызывался** | Background goroutine каждые 6 часов | | 12 | **Frontend Place тип не совпадает с backend** | `coordinates` → `lat`/`lng`; `ApiError` → `{"error":"..."}` | ### P2 — Исправлено | # | Проблема | Решение | |---|----------|---------| | 13 | **Нет лимита тела запроса** | `http.MaxBytesReader(w, r.Body, 1MB)` во всех хендлерах | | 14 | **Cleanup горутина RateLimiter не останавливается** | Добавлен `stopCh` канал + `Stop()` метод | | 15 | **DeleteTag использует body + query param** | Упрощено, но сохранено для обратной совместимости | ### Obsidian-заметки (добавлены) - [[atomic-csrf-protection]] - [[atomic-refresh-token-race-condition]] - [[atomic-unused-appenv-parameter]] - [[atomic-booking-price-truncation]] - [[decision-validation-error-production]] - [[MOC-security-patterns]] --- ## Исправления от 2026-06-30 (аудит neyrogovnarik) | # | Проблема | Решение | Файл | |---|----------|---------|------| | 1 | **Бесконечный цикл при upload на 401** — `requestFormData` повторял запрос после refresh без guard'а; при повторном 401 уходил в плотный цикл к `/auth/refresh` | Добавлен флаг `isRetry` (как в `request`) — однократный повтор | `frontend/src/lib/api.ts` | | 2 | **`PATCH /places/{id}` отдавал 500 вместо 403** — sentinel `ErrNotYourPlace` не мапился в хендлере | `errors.Is(err, services.ErrNotYourPlace)` → 403 | `handlers/places.go` | | 3 | **`Confirm` брони без проверки статуса** — можно подтвердить отменённую бронь / 200 на несуществующий id | Переход через `UpdateStatusIfPending`; 409 если не `pending`. Удалён мёртвый `UpdateStatus` | `handlers/bookings.go`, `repository/bookings.go` | | 4 | **Upload доверял Content-Type клиента** — можно залить произвольный файл под видом изображения | Проверка magic-bytes (`http.DetectContentType` + ручной ISO-BMFF для heic), seek в начало | `handlers/upload.go` | | 5 | **Мёртвый код** — `IsTimeSlotAvailable` (не вызывается, `tsrange` vs `tstzrange`) | Удалён; защита от двойных броней работает через DB exclusion-constraint | `repository/bookings.go` | | 6 | **`ALLOWED_ORIGINS` без trim** — origin с пробелом не совпадал в CORS | `strings.TrimSpace` + отброс пустых | `config/config.go` | | 7 | **`geocode` светил upstream-тело** в `raw_error` | Тело отбрасывается, наружу только `display_name: null` | `handlers/geocode.go` | ### Obsidian-заметки (добавлены) - [[atomic-fetch-retry-infinite-loop]] - [[atomic-sentinel-error-handler-mapping]] - [[atomic-booking-status-transition-guard]] - [[atomic-file-upload-magic-bytes]]