From e3b4e3c73fe4ef027a63be5cd4f164054b7f3e9a Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sat, 15 Aug 2026 22:23:16 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D1=81=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Штатные /docs и /openapi.json не требуют токена, поэтому раньше были выключены совсем. Теперь это свои маршруты, закрытые тем же списком адресов, что и остальной сервис: со своей машины открываются, с чужой отдают 403. У методов появились описания, в схеме объявлен Bearer - работает кнопка Authorize. Co-Authored-By: Claude Opus 5 (1M context) --- app/main.py | 67 ++++++++++++++++++++++++++++++++++++++------- app/version.py | 2 +- tests/test_api.py | 69 +++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/app/main.py b/app/main.py index d939e7a..f02745b 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,10 @@ from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse +from fastapi.security import HTTPBearer from app.config import Settings, load_settings from app.pipeline import ModelsMissing, Pipeline, to_wav16k @@ -91,16 +94,29 @@ async def lifespan(app: FastAPI): worker.join(timeout=5) -# Схема API не требует токена, поэтому по умолчанию она не публикуется: -# знать устройство сервиса посторонним незачем. +# Штатные /docs и /openapi.json отключены: они не требуют токена. Вместо них +# ниже свои маршруты, закрытые тем же списком адресов, что и остальной сервис. app = FastAPI( title="talkscore-asr", - version="0.1.0", + version=__version__, + description=( + "Локальное распознавание русской речи с разделением по говорящим.\n\n" + "Все методы, кроме `/health`, требуют заголовок `Authorization: Bearer <токен>`. " + "Нажмите **Authorize** и вставьте токен из `config.toml`." + ), lifespan=lifespan, - docs_url="/docs" if settings.docs else None, - redoc_url="/redoc" if settings.docs else None, - openapi_url="/openapi.json" if settings.docs else None, + docs_url=None, + redoc_url=None, + openapi_url=None, ) +bearer_scheme = HTTPBearer(description="Токен из config.toml, раздел [security]") + + +def ip_guard(request: Request) -> None: + """Только проверка адреса: нужна для страницы с описанием методов.""" + client_ip = request.client.host if request.client else None + if not ip_allowed(client_ip, allowlist): + raise HTTPException(status_code=403, detail="адрес не в списке разрешённых") def guard(request: Request) -> None: @@ -113,7 +129,29 @@ def guard(request: Request) -> None: raise HTTPException(status_code=401, detail="неверный или отсутствующий токен") -@app.get("/health") +@app.get("/docs", include_in_schema=False) +def docs_page(request: Request): + """Описание методов для браузера. Открывается только с разрешённых адресов.""" + ip_guard(request) + return get_swagger_ui_html(openapi_url="openapi.json", title="talkscore-asr") + + +@app.get("/openapi.json", include_in_schema=False) +def openapi_schema(request: Request) -> JSONResponse: + ip_guard(request) + schema = get_openapi(title=app.title, version=app.version, + description=app.description, routes=app.routes) + schema["components"] = schema.get("components", {}) + schema["components"]["securitySchemes"] = { + "bearer": {"type": "http", "scheme": "bearer"}} + schema["security"] = [{"bearer": []}] + return JSONResponse(schema) + + +@app.get("/health", summary="Состояние сервиса", + description="Единственный метод без токена - годится для мониторинга. " + "Показывает версию, очередь, число потоков и то, каким сервис " + "видит ваш адрес.") def health(request: Request) -> JSONResponse: """Проверка живости - без токена, чтобы годилась для мониторинга. @@ -134,7 +172,11 @@ def health(request: Request) -> JSONResponse: }) -@app.post("/v1/jobs", dependencies=[Depends(guard)]) +@app.post("/v1/jobs", dependencies=[Depends(guard)], summary="Отправить запись", + description="Принимает аудиофайл любого формата, который читает ffmpeg: " + "mp3, wav, m4a, ogg, opus, wma. Возвращает идентификатор задачи - " + "результат забирается отдельным запросом, потому что обработка " + "занимает минуты.") async def create_job( file: UploadFile = File(...), speakers: int | None = Query(None, ge=0, le=10, @@ -169,7 +211,10 @@ async def create_job( "queue_position": store.queue_position(job_id)} -@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)]) +@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Забрать результат", + description="Пока задача не готова, возвращает статус и место в очереди. " + "Готовый результат содержит реплики с говорящими и тайм-кодами, " + "статистику разговора и время обработки.") def get_job(job_id: str) -> dict: job = store.get(job_id) if job is None: @@ -185,7 +230,9 @@ def get_job(job_id: str) -> dict: return body -@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)]) +@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Удалить задачу", + description="Убирает задачу и её результат. Завершённые задачи и так " + "удаляются сами через срок из keep_results_hours.") def delete_job(job_id: str) -> dict: if store.get(job_id) is None: raise HTTPException(status_code=404, detail="задача не найдена") diff --git a/app/version.py b/app/version.py index 0a8da88..f1380ee 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.1.7" diff --git a/tests/test_api.py b/tests/test_api.py index 38beb4a..ee5044a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -113,15 +113,9 @@ class TestHealth: class TestSchemaExposure: - """Схема API не требует токена, поэтому по умолчанию она закрыта.""" + """Штатный redoc остаётся выключенным: своя страница только одна.""" - def test_docs_hidden_by_default(self, client): - assert client.get("/docs").status_code == 404 - - def test_openapi_hidden_by_default(self, client): - assert client.get("/openapi.json").status_code == 404 - - def test_redoc_hidden_by_default(self, client): + def test_redoc_is_off(self, client): assert client.get("/redoc").status_code == 404 @@ -164,3 +158,62 @@ class TestConfigFileIsNotCode: src = Path(__file__).resolve().parent.parent / "app" / "config.py" text = src.read_text(encoding="utf-8") assert "ЭТО КОД, А НЕ НАСТРОЙКИ" in text + + +class TestDocsPage: + """Страница с методами полезна, но открывать её всем подряд незачем.""" + + def test_docs_available_when_ip_allowed(self, client): + # в тестовом конфиге список адресов пуст = ограничение выключено + assert client.get("/docs").status_code == 200 + + def test_openapi_available_when_ip_allowed(self, client): + assert client.get("/openapi.json").status_code == 200 + + def test_docs_need_no_token(self, client): + client.headers.pop("Authorization") + assert client.get("/docs").status_code == 200 + + def test_schema_declares_bearer_auth(self, client): + schema = client.get("/openapi.json").json() + assert "bearer" in schema["components"]["securitySchemes"] + + def test_schema_lists_all_endpoints(self, client): + paths = client.get("/openapi.json").json()["paths"] + assert "/v1/jobs" in paths and "/health" in paths + + +@pytest.fixture +def restricted_client(tmp_path, monkeypatch): + """Приложение со списком адресов, куда клиент тестов не входит.""" + config = tmp_path / "config.toml" + config.write_text( + f'[server]\nhost="127.0.0.1"\nport=8756\n' + f'[security]\ntoken="{TOKEN}"\nallow_ips="10.1.2.3"\n' + f'[processing]\nthreads=1\n', + encoding="utf-8") + monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path)) + for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"): + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + for mod in [m for m in list(sys.modules) if m.startswith("app.")]: + del sys.modules[mod] + import app.config as cfg + monkeypatch.setattr(cfg, "BASE_DIR", tmp_path) + import app.main as main + monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False) + main._state["ready"] = True + main._worker_stop.set() + with TestClient(main.app) as c: + yield c + + +class TestDocsAccessControl: + def test_docs_closed_for_foreign_ip(self, restricted_client): + assert restricted_client.get("/docs").status_code == 403 + + def test_openapi_closed_for_foreign_ip(self, restricted_client): + assert restricted_client.get("/openapi.json").status_code == 403 + + def test_health_stays_open_for_foreign_ip(self, restricted_client): + """Мониторинг должен работать всегда.""" + assert restricted_client.get("/health").status_code == 200