diff --git a/HISTORY/2026-08-28-obdai-receipt.md b/HISTORY/2026-08-28-obdai-receipt.md index a71b778..47dcc72 100644 --- a/HISTORY/2026-08-28-obdai-receipt.md +++ b/HISTORY/2026-08-28-obdai-receipt.md @@ -330,6 +330,18 @@ hex вместо Base64. Узкий тест выявил проблему; ко Контрольный запрос с `PLAN/lekar.png` через `POST /gemini` на localhost ВМ: HTTP 200. +## Защита recipe API + +По команде пользователя добавлена Bearer-защита endpoint `POST /recipe`. +Приложение сравнивает заголовок `Authorization` со значением +`RECIPE_API_TOKEN` из EnvironmentFile; при отсутствии или неверном токене +возвращается HTTP 401. Health endpoint остаётся доступен без авторизации. + +Добавлен `recipe_service/.gitignore`, исключающий `.env`, `*.env`, +`__pycache__` и `*.pyc`. В `.env.example` добавлено только имя настройки без +секретного значения. Реальный случайный токен будет храниться только на ВМ в +`/etc/recipe/recipe.env`; в git и HISTORY он не записывается. + Ответ модели: ```json @@ -467,3 +479,20 @@ S. по 1т 1/р в день `thoughtsTokenCount` в ответе отсутствует, что подтверждает отсутствие отдельно тарифицируемых reasoning-токенов в этом запросе. Usage: prompt 1119, image 1102, text 17, candidate output 34, total 1153. + +## Bearer-защита recipe API + +Добавлена Bearer-защита `POST /recipe`: без заголовка или с неверным значением +`Authorization: Bearer ...` сервис возвращает HTTP 401. `/health` остаётся +доступен без авторизации. + +Случайный токен установлен на ВМ 213 в `/etc/recipe/recipe.env` с правами +`0640` и владельцем `root:recipe`; значение токена не записывалось в git или +HISTORY. Добавлен `recipe_service/.gitignore` для `.env`, `*.env`, +`__pycache__` и `*.pyc`. + +Синтаксис `recipe_service/app.py`, `git diff --check` и локальная проверка Flask +прошли. После перезапуска `recipe.service` запрос без токена получил HTTP 401. +Авторизованный запрос дошёл до Gemini, но получил HTTP 502: `API key not valid`. +Следовательно, Bearer-защита работает, а `GEMINI_API_KEY` в серверном env-файле +недействителен и требует замены. diff --git a/recipe_service/.env.example b/recipe_service/.env.example index d7ccced..8048363 100644 --- a/recipe_service/.env.example +++ b/recipe_service/.env.example @@ -1,3 +1,4 @@ GEMINI_API_KEY=replace-with-secret GEMINI_MODEL=gemini-3.6-flash -GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}} \ No newline at end of file +GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}} +RECIPE_API_TOKEN=replace-with-random-token \ No newline at end of file diff --git a/recipe_service/.gitignore b/recipe_service/.gitignore new file mode 100644 index 0000000..97c0661 --- /dev/null +++ b/recipe_service/.gitignore @@ -0,0 +1,4 @@ +.env +*.env +__pycache__/ +*.pyc \ No newline at end of file diff --git a/recipe_service/README.md b/recipe_service/README.md index bb0623e..95c1f24 100644 --- a/recipe_service/README.md +++ b/recipe_service/README.md @@ -3,11 +3,14 @@ Минимальный Flask-сервис на ВМ `5.172.178.213`. Вход: `POST /recipe` в формате `multipart/form-data` с полями `image` и -`prompt`. Изображение: JPEG/PNG/WEBP, не более 10 MB. +`prompt`. Требуется заголовок `Authorization: Bearer `. +Изображение: JPEG/PNG/WEBP, не более 10 MB. Ключ Gemini и JSON-строка `GEMINI_GENERATION_CONFIG` находятся только в серверном EnvironmentFile. Сервис передаёт изображение, prompt и настройки в Gemini и возвращает `text` и `usage`; разбор рецепта выполняется позднее. +Токен `RECIPE_API_TOKEN` также хранится только в EnvironmentFile и не +передаётся в Gemini. Сервис изолирован от `elmer`: отдельные каталог, virtualenv, пользователь, порт, systemd-юнит и настройки nginx. Изображения на диск не сохраняются. \ No newline at end of file diff --git a/recipe_service/app.py b/recipe_service/app.py index e95746a..d4effdd 100644 --- a/recipe_service/app.py +++ b/recipe_service/app.py @@ -25,6 +25,12 @@ def settings() -> tuple[str, dict]: return key, config +def authorized() -> bool: + expected = os.environ.get("RECIPE_API_TOKEN") + authorization = request.headers.get("Authorization", "") + return bool(expected and authorization == f"Bearer {expected}") + + @app.get("/health") def health(): return jsonify(status="ok") @@ -32,6 +38,8 @@ def health(): @app.post("/recipe") def recipe(): + if not authorized(): + return jsonify(error="unauthorized"), 401 image = request.files.get("image") prompt = request.form.get("prompt") if image is None or not prompt: