diff --git a/API_DOCS.md b/API_DOCS.md deleted file mode 100644 index 1b59557..0000000 --- a/API_DOCS.md +++ /dev/null @@ -1,244 +0,0 @@ -# RMSER Backend API Documentation (v2.0) - -**Дата обновления:** Текущая -**Base URL:** `http://localhost:8080/api` - -## 1. Типы данных (TypeScript Interfaces) - -Используйте эти интерфейсы для строгой типизации на клиенте. - -```typescript -// Базовые типы -type UUID = string; - -// --- Каталог и Фасовки --- - -export interface MeasureUnit { - id: UUID; - name: string; // "кг", "л", "шт", "порц" - code: string; -} - -export interface ProductContainer { - id: UUID; - name: string; // "Коробка", "Пачка 200г" - count: number; // Коэффициент пересчета в базовые единицы (напр. 0.2 или 12.0) -} - -export interface CatalogItem { - id: UUID; - name: string; - code: string; - measure_unit: string; // Название базовой единицы (из MainUnit) - containers: ProductContainer[]; // Доступные фасовки -} - -// --- Матчинг (Обучение) --- - -export interface MatchRequest { - raw_name: string; // Текст из чека - product_id: UUID; // ID товара iiko - quantity: number; // Количество (по умолчанию 1.0) - container_id?: UUID; // Опционально: ID фасовки, если выбрана -} - -export interface SavedMatch { - raw_name: string; - product: CatalogItem; // Вложенный объект товара - quantity: number; // Сохраненный коэффициент/количество - container_id?: UUID; - container?: ProductContainer; // Вложенный объект фасовки (для отображения имени) - updated_at: string; -} - -// --- Нераспознанное --- - -export interface UnmatchedItem { - raw_name: string; - count: number; // Сколько раз встречалось в чеках - last_seen: string; // ISO Date -} -``` - ---- - -## 2. Эндпоинты: OCR и Обучение - -### `GET /ocr/catalog` -Получить полный справочник товаров для поиска. -Теперь включает **единицы измерения** и **фасовки**. - -* **Response:** `200 OK` - -```json -[ - { - "id": "uuid-butter...", - "name": "Масло сливочное 82%", - "code": "00123", - "measure_unit": "кг", - "containers": [ - { - "id": "uuid-pack...", - "name": "Пачка 180г", - "count": 0.180 - }, - { - "id": "uuid-box...", - "name": "Коробка (20 пачек)", - "count": 3.6 - } - ] - }, - { - "id": "uuid-milk...", - "name": "Молоко 3.2%", - "code": "00124", - "measure_unit": "л", - "containers": [] // Пустой массив, если фасовок нет - } -] -``` - ---- - -### `GET /ocr/matches` -Получить список уже обученных позиций. Используется для отображения таблицы "Ранее сохраненные связи". - -* **Response:** `200 OK` - -```json -[ - { - "raw_name": "Масло слив. 3 пачки", - "product_id": "uuid-butter...", - "product": { - "id": "uuid-butter...", - "name": "Масло сливочное 82%", - "measure_unit": "кг" - // ...остальные поля product - }, - "quantity": 3, - "container_id": "uuid-pack...", - "container": { - "id": "uuid-pack...", - "name": "Пачка 180г", - "count": 0.180 - }, - "updated_at": "2023-10-27T10:00:00Z" - } -] -``` -> **Логика отображения на фронте:** -> Если `container` пришел (не null) -> отображаем: `Quantity` x `Container.Name` (3 x Пачка 180г). -> Если `container` null -> отображаем: `Quantity` `Product.MeasureUnit` (0.54 кг). - ---- - -### `POST /ocr/match` -Создать или обновить привязку. - -* **Body:** - -**Вариант 1: Базовая единица (без фасовки)** -Пользователь выбрал "Петрушка", ввел "0.5" (кг). -```json -{ - "raw_name": "петрушка вес", - "product_id": "uuid-parsley...", - "quantity": 0.5 - // container_id не передаем или null -} -``` - -**Вариант 2: С фасовкой** -Пользователь выбрал "Масло", выбрал фасовку "Коробка", ввел "2" (штуки). -```json -{ - "raw_name": "масло коробка", - "product_id": "uuid-butter...", - "quantity": 2, - "container_id": "uuid-box..." -} -``` - -* **Response:** `200 OK` -> `{"status": "saved"}` - ---- - -### `GET /ocr/unmatched` -Получить список частых нераспознанных товаров. -Используется для автодополнения (Suggest) в поле ввода "Текст из чека", чтобы пользователь не вводил название вручную. - -* **Response:** `200 OK` - -```json -[ - { - "raw_name": "Хлеб Бородинский нар.", - "count": 12, - "last_seen": "2023-10-27T12:00:00Z" - }, - { - "raw_name": "Пакет майка", - "count": 5, - "last_seen": "2023-10-26T09:00:00Z" - } -] -``` - ---- - -## 3. Эндпоинты: Накладные (Invoices) - -### `POST /invoices/send` -Отправка накладной в iiko. - -**Важно:** При формировании накладной на фронте, вы должны пересчитывать количество в базовые единицы, либо (в будущем) мы научим бэкенд принимать ID фасовки в `items`. -На данный момент API ожидает `amount` и `price` уже приведенными к единому знаменателю, либо iiko сама разберется, если мы передадим ContainerID (этот функционал в разработке на стороне `rms_client`, пока шлите базовые единицы). - -```json -{ - "document_number": "INV-100", - "date_incoming": "2023-10-27", - "supplier_id": "uuid...", - "store_id": "uuid...", - "items": [ - { - "product_id": "uuid...", - "amount": 1.5, // 1.5 кг - "price": 100 // Цена за кг - } - ] -} -``` - ---- - -## 4. Эндпоинты: Аналитика - -### `GET /recommendations` -Возвращает список проблем (товары без техкарт, без закупок и т.д.). - -```json -[ - { - "ID": "uuid...", - "Type": "UNUSED_IN_RECIPES", - "ProductID": "uuid...", - "ProductName": "Лист салата", - "Reason": "Товар не используется ни в одной техкарте", - "CreatedAt": "..." - } -] -``` - ---- - -## 5. System - -### `GET /health` -Проверка статуса. -```json -{"status": "ok", "time": "..."} -``` \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 4795ef3..46ef81c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -115,7 +115,7 @@ func main() { api := r.Group("/api") - api.Use(middleware.AuthMiddleware(accountRepo)) + api.Use(middleware.AuthMiddleware(accountRepo, cfg.Telegram.Token)) { // Drafts & Invoices api.GET("/drafts", draftsHandler.GetDrafts) diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go index a215b26..4880393 100644 --- a/internal/transport/http/middleware/auth.go +++ b/internal/transport/http/middleware/auth.go @@ -1,49 +1,129 @@ package middleware import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" "net/http" + "net/url" + "sort" "strconv" + "strings" "rmser/internal/domain/account" "github.com/gin-gonic/gin" ) -// AuthMiddleware извлекает Telegram User ID и находит User UUID -func AuthMiddleware(accountRepo account.Repository) gin.HandlerFunc { +// AuthMiddleware проверяет initData от Telegram +func AuthMiddleware(accountRepo account.Repository, botToken string) gin.HandlerFunc { return func(c *gin.Context) { - // 1. Ищем в заголовке (стандартный путь) - tgIDStr := c.GetHeader("X-Telegram-User-ID") + // 1. Извлекаем данные авторизации + authHeader := c.GetHeader("Authorization") + var initData string - // 2. Если нет в заголовке, ищем в Query (для отладки в браузере) - // Пример: /api/drafts?_tg_id=12345678 - if tgIDStr == "" { - tgIDStr = c.Query("_tg_id") + if strings.HasPrefix(authHeader, "Bearer ") { + initData = strings.TrimPrefix(authHeader, "Bearer ") + } else { + // Оставляем лазейку для отладки ТОЛЬКО если это не production режим + // В реальности лучше всегда требовать подпись + initData = c.Query("_auth") } - if tgIDStr == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Telegram-User-ID header or _tg_id param"}) + if initData == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Авторизация отклонена: отсутствует подпись Telegram"}) return } - tgID, err := strconv.ParseInt(tgIDStr, 10, 64) + // 2. Проверяем подпись (HMAC-SHA256) + isValid, err := verifyTelegramInitData(initData, botToken) + if !isValid || err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Критическая ошибка безопасности: поддельная подпись"}) + return + } + + // 3. Извлекаем User ID из проверенных данных + values, _ := url.ParseQuery(initData) + userJSON := values.Get("user") + + // Извлекаем id вручную из JSON-подобной строки или через простой парсинг + // Telegram передает user как JSON-объект: {"id":12345,"first_name":"..."} + tgID, err := extractIDFromUserJSON(userJSON) if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Telegram ID"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Не удалось извлечь Telegram ID"}) return } - // Ищем пользователя в БД + // 4. Ищем пользователя в БД user, err := accountRepo.GetUserByTelegramID(tgID) if err != nil { - // Если пользователя нет - значит он не нажал /start в боте - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not registered via Bot. Please start the bot first."}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Пользователь не зарегистрирован. Начните диалог с ботом."}) return } - // Кладем UUID пользователя в контекст c.Set("userID", user.ID) c.Set("telegramID", tgID) - c.Next() } } + +// verifyTelegramInitData реализует алгоритм проверки Telegram +func verifyTelegramInitData(initData, token string) (bool, error) { + values, err := url.ParseQuery(initData) + if err != nil { + return false, err + } + + hash := values.Get("hash") + if hash == "" { + return false, fmt.Errorf("no hash found") + } + values.Del("hash") + + // Сортируем ключи + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + + // Собираем data_check_string + var dataCheckArr []string + for _, k := range keys { + dataCheckArr = append(dataCheckArr, fmt.Sprintf("%s=%s", k, values.Get(k))) + } + dataCheckString := strings.Join(dataCheckArr, "\n") + + // Вычисляем секретный ключ: HMAC-SHA256("WebAppData", token) + sha := sha256.New() + sha.Write([]byte(token)) + + secretKey := hmac.New(sha256.New, []byte("WebAppData")) + secretKey.Write([]byte(token)) + + // Вычисляем финальный HMAC + h := hmac.New(sha256.New, secretKey.Sum(nil)) + h.Write([]byte(dataCheckString)) + expectedHash := hex.EncodeToString(h.Sum(nil)) + + return expectedHash == hash, nil +} + +// Упрощенное извлечение ID из JSON-строки поля user +func extractIDFromUserJSON(userJSON string) (int64, error) { + // Ищем "id":(\d+) + // Для надежности в будущем можно использовать json.Unmarshal + startIdx := strings.Index(userJSON, "\"id\":") + if startIdx == -1 { + return 0, fmt.Errorf("id not found") + } + startIdx += 5 + endIdx := strings.IndexAny(userJSON[startIdx:], ",}") + if endIdx == -1 { + return 0, fmt.Errorf("invalid json") + } + + idStr := userJSON[startIdx : startIdx+endIdx] + return strconv.ParseInt(idStr, 10, 64) +} diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index c900fe5..ea63e91 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -9,8 +9,8 @@ import { DraftsList } from "./pages/DraftsList"; import { SettingsPage } from "./pages/SettingsPage"; import { UNAUTHORIZED_EVENT } from "./services/api"; -// Компонент заглушки для 401 ошибки -const UnauthorizedScreen = () => ( +// Компонент-заглушка для внешних браузеров +const NotInTelegramScreen = () => (