// Package handler — HTTP-обработчики IoT REST API (новый монолит). // // Отличие от старого кода: устройства — в PostgreSQL (таблица iot_devices), // без k8s-клиента и CRD. Телеметрия — через iotpg, как раньше. package handler import ( "encoding/json" "log/slog" "net/http" "time" "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/gorilla/mux" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/config" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/store" "gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg" ) // Handler — зависимости всех HTTP-обработчиков. type Handler struct { Devices *store.DeviceStore IoTPG *iotpg.IoTPostgresStore SQS *sqs.Client Cfg *config.Config Log *slog.Logger // Version — версия бинарника (ldflags), StartedAt — для админ-статистики. Version string StartedAt time.Time } // writeJSON отправляет JSON-ответ с указанным статусом. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // maxRequestBodyBytes — максимум размера тела запроса (устройства/метаданные // небольшие; 64 КиБ с запасом). const maxRequestBodyBytes = 64 * 1024 // decodeJSON читает JSON-тело с лимитом размера и запретом неизвестных полей // (ревью 2026-10-10). MaxBytesReader защищает от раздутых тел; // DisallowUnknownFields ловит опечатки в полях — критично для Terraform (лучше // 400 с понятной ошибкой, чем молча проигнорированное поле). func decodeJSON(w http.ResponseWriter, r *http.Request, v any) error { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() return dec.Decode(v) } // errResp возвращает структуру ошибки для JSON. func errResp(msg string) map[string]string { return map[string]string{"error": msg} } // pathVar читает переменную пути из gorilla/mux. func pathVar(r *http.Request, key string) string { return mux.Vars(r)[key] } // defaultNamespace — namespace, когда в пути он не задан. const defaultNamespace = "default" // namespaceFromPath возвращает {namespace} из URL (пусто → "default"). func namespaceFromPath(r *http.Request) string { ns := pathVar(r, "namespace") if ns == "" { ns = defaultNamespace } return ns } // requireNamespace — namespace из URL + его валидация. При невалидном значении // отвечает 400 и возвращает ok=false (вызывающий обязан выйти). // // Зачем единая точка: раньше проверка была ТОЛЬКО в CreateIoTDevice, а // GET/DELETE/PATCH/telemetry пропускали любой namespace дальше (тест 2026-10-11: // `GET /v1/namespaces/bad_ns/iot/devices` → 200, а чтение телеметрии с таким ns // падало уже в хранилище с 500). Валидируем на входе у всех — единообразный 400. func requireNamespace(w http.ResponseWriter, r *http.Request) (string, bool) { ns := namespaceFromPath(r) if msg := validateIdentifier(ns, "namespace", 56); msg != "" { writeJSON(w, http.StatusBadRequest, errResp(msg)) return "", false } return ns, true }