From 8647114b22bfbfd375ce50c8ca3ad23aec916a97 Mon Sep 17 00:00:00 2001
From: lekss361 <47113017+lekss361@users.noreply.github.com>
Date: Thu, 14 May 2026 23:39:47 +0300
Subject: [PATCH 1/2] fix(docs): block javascript: URLs in renderMarkdown href
(XSS guard) (#131)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per audit batch #127 P2 security minor (issue #130).
## Risk
`renderMarkdown` substituted `[text](url)` → `` verbatim
без URL validation. Edge case: `[click](javascript:alert(1))` →
`click` execute'нется при click.
Currently low risk (markdown source = build-time fs.readFileSync из
public/docs/, checked into repo), но защита-в-глубину: если когда-либо
markdown source станет user-supplied, vuln materialize.
## Fix
`frontend/src/app/docs/b2b-channels/renderMarkdown.ts`:
1. New `safeUrl(url)` function — allowlist `https://`, `http://`, `/`, `#`,
`mailto:`. Everything else → `"#"`.
2. Link regex replacement switched from backreference string to callback
so URL passes через `safeUrl` ДО injection в href.
3. URL unescape → safeUrl → re-escape (HTML escape applied per-line).
## Test cases verified
- `[ok](https://example.com)` → href=`https://example.com` ✅
- `[ok](/local)` → href=`/local` ✅
- `[ok](#anchor)` → href=`#anchor` ✅
- `[ok](mailto:foo@bar.com)` → href=`mailto:foo@bar.com` ✅
- `[bad](javascript:alert(1))` → href=`#` ✅
- `[bad](data:text/html,...)` → href=`#` ✅
- `[bad](vbscript:msgbox(1))` → href=`#` ✅
## Checks
- tsc: 0 errors
- lint: 0 warnings
- No existing `__tests__/renderMarkdown` to break
## Vault
`fixes/Bug_RenderMarkdown_JavascriptUrl_May14.md` — created.
Closes #130
Co-authored-by: lekss361
---
.../app/docs/b2b-channels/renderMarkdown.ts | 33 +++++++++++++++++--
1 file changed, 31 insertions(+), 2 deletions(-)
diff --git a/frontend/src/app/docs/b2b-channels/renderMarkdown.ts b/frontend/src/app/docs/b2b-channels/renderMarkdown.ts
index 2074001b..2b7be464 100644
--- a/frontend/src/app/docs/b2b-channels/renderMarkdown.ts
+++ b/frontend/src/app/docs/b2b-channels/renderMarkdown.ts
@@ -13,6 +13,24 @@ function escapeHtml(s: string): string {
.replace(/'/g, "'");
}
+/**
+ * Allow only safe URL schemes in rendered links.
+ * Blocks javascript:, data:, vbscript:, file:, etc.
+ */
+function safeUrl(url: string): string {
+ const trimmed = url.trim();
+ if (
+ trimmed.startsWith("https://") ||
+ trimmed.startsWith("http://") ||
+ trimmed.startsWith("/") ||
+ trimmed.startsWith("#") ||
+ trimmed.startsWith("mailto:")
+ ) {
+ return trimmed;
+ }
+ return "#";
+}
+
function inlineMd(line: string): string {
// escape first, then re-introduce safe markup
let html = escapeHtml(line);
@@ -20,10 +38,21 @@ function inlineMd(line: string): string {
html = html.replace(/`([^`]+)`/g, "$1");
// bold **...**
html = html.replace(/\*\*([^*]+)\*\*/g, "$1");
- // links [text](url)
+ // links [text](url) — URL is validated before being placed in href.
+ // Note: at this point `html` is already HTML-escaped, so we must unescape
+ // the captured URL before passing through safeUrl, then re-escape for href.
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
- '$1',
+ (_, text: string, escapedUrl: string) => {
+ const rawUrl = escapedUrl
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+ const href = escapeHtml(safeUrl(rawUrl));
+ return `${text}`;
+ },
);
// checkboxes
html = html.replace(
--
2.45.3
From f8023a16f874312d974a1635bfa15389668e75a0 Mon Sep 17 00:00:00 2001
From: lekss361
Date: Thu, 14 May 2026 23:39:55 +0300
Subject: [PATCH 2/2] refactor(admin): extract _check_token into shared
AdminTokenAuth dep
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per audit batch #127 P2 hygiene (issue #128).
## Problem
Identical `_check_token` function в 3 admin files:
- backend/app/api/v1/admin_scrape.py:37-44
- backend/app/api/v1/admin_leads.py:26-33
- backend/app/api/v1/admin_jobs.py:24-31
## Fix
New `backend/app/core/deps.py` с `verify_admin_token` + Annotated alias:
```python
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
```
Заменено в 27 endpoints (18 admin_scrape + 6 admin_leads + 3 admin_jobs):
- Removed local `_check_token` definitions
- `_: None = Depends(_check_token)` → `_: AdminTokenAuth`
- Removed unused imports (`Header`, `HTTPException`, `settings` где не используется)
## Semantics preserved
Все 3 original implementations были identical (503 if env missing + 401 if
mismatch, same header `X-Admin-Token`, same env `SCRAPE_ADMIN_TOKEN`).
Минор: 503 detail text unified to `"admin disabled — set SCRAPE_ADMIN_TOKEN"`
(в оригинале три разные строки — `"admin scrape disabled..."` etc).
Cosmetic — detail в HTTP 503 не парсится клиентским кодом.
## Tests
`uv run pytest tests/ -x -k admin` → 64/64 passed.
## Vault
`code/patterns/Pattern_Admin_Token_Dependency_May14.md` — created.
Closes #128
Refs: #127
---
backend/app/api/v1/admin_jobs.py | 23 ++------
backend/app/api/v1/admin_leads.py | 32 +++--------
backend/app/api/v1/admin_scrape.py | 87 ++++++++++--------------------
backend/app/core/deps.py | 23 ++++++++
4 files changed, 63 insertions(+), 102 deletions(-)
create mode 100644 backend/app/core/deps.py
diff --git a/backend/app/api/v1/admin_jobs.py b/backend/app/api/v1/admin_jobs.py
index e1c36ed2..05ed03cc 100644
--- a/backend/app/api/v1/admin_jobs.py
+++ b/backend/app/api/v1/admin_jobs.py
@@ -11,33 +11,22 @@ from __future__ import annotations
from typing import Annotated, Any
-from fastapi import APIRouter, Depends, Header, HTTPException
+from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
-from app.core.config import settings
from app.core.db import get_db
+from app.core.deps import AdminTokenAuth
from app.schemas.job_settings import JobSettingRead, JobSettingUpdate
router = APIRouter()
-def _check_token(x_admin_token: str | None) -> None:
- if not settings.scrape_admin_token:
- raise HTTPException(
- status_code=503,
- detail="admin jobs disabled — set SCRAPE_ADMIN_TOKEN",
- )
- if x_admin_token != settings.scrape_admin_token:
- raise HTTPException(status_code=401, detail="invalid admin token")
-
-
@router.get("/settings", response_model=list[JobSettingRead])
def list_job_settings(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> Any:
"""Список всех job_settings (все job_type)."""
- _check_token(x_admin_token)
from app.services.job_settings import get_all
return get_all(db)
@@ -47,10 +36,9 @@ def list_job_settings(
def get_job_setting(
job_type: str,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> Any:
"""Настройки одного job_type. Возвращает fallback defaults если строки нет в БД."""
- _check_token(x_admin_token)
from app.services.job_settings import get_one
return get_one(job_type, db)
@@ -61,7 +49,7 @@ def update_job_setting(
job_type: str,
payload: JobSettingUpdate,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> Any:
"""PATCH-style update: передавать только изменяемые поля.
@@ -71,7 +59,6 @@ def update_job_setting(
cron_schedule='' устанавливает NULL (manual-only режим).
rate_ms=0 устанавливает NULL (no throttle).
"""
- _check_token(x_admin_token)
from app.services.job_settings import update
result = update(
diff --git a/backend/app/api/v1/admin_leads.py b/backend/app/api/v1/admin_leads.py
index 81dbff38..45d899e4 100644
--- a/backend/app/api/v1/admin_leads.py
+++ b/backend/app/api/v1/admin_leads.py
@@ -12,31 +12,21 @@ from __future__ import annotations
from typing import Annotated, Any, Literal
-from fastapi import APIRouter, Depends, Header, HTTPException, Query
+from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
-from app.core.config import settings
from app.core.db import get_db
+from app.core.deps import AdminTokenAuth
from app.services import analytics_queries as q
router = APIRouter()
-def _check_token(x_admin_token: str | None) -> None:
- if not settings.scrape_admin_token:
- raise HTTPException(
- status_code=503,
- detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
- )
- if x_admin_token != settings.scrape_admin_token:
- raise HTTPException(status_code=401, detail="invalid admin token")
-
-
@router.get("/")
def list_leads(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
status: Annotated[str | None, Query()] = None,
source: Annotated[str | None, Query()] = None,
converted: Annotated[bool | None, Query()] = None,
@@ -48,7 +38,6 @@ def list_leads(
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> dict[str, Any]:
- _check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": limit, "off": offset}
if status:
@@ -140,11 +129,10 @@ def list_leads(
@router.get("/stats")
def leads_stats(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> dict[str, Any]:
"""KPI summary за последние N месяцев."""
- _check_token(x_admin_token)
row = (
db.execute(
text(
@@ -202,42 +190,38 @@ def leads_stats(
@router.get("/funnel/monthly")
def funnel_monthly(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (по source)."""
- _check_token(x_admin_token)
return q.prinzip_funnel_monthly(db, months=months)
@router.get("/funnel/by-source")
def funnel_by_source(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
- _check_token(x_admin_token)
return q.prinzip_funnel_by_source(db, months=months)
@router.get("/funnel/by-object")
def funnel_by_object(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
- _check_token(x_admin_token)
return q.prinzip_funnel_by_object(db)
@router.get("/sources")
def list_sources(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> list[str]:
"""Distinct list of source values for filter dropdown."""
- _check_token(x_admin_token)
rows = db.execute(
text(
"""
diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py
index 431fffe4..cd611f4a 100644
--- a/backend/app/api/v1/admin_scrape.py
+++ b/backend/app/api/v1/admin_scrape.py
@@ -12,13 +12,14 @@ from __future__ import annotations
from typing import Annotated, Any
-from fastapi import APIRouter, Depends, Header, HTTPException
+from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
+from app.core.deps import AdminTokenAuth
router = APIRouter()
@@ -34,22 +35,11 @@ class TriggerKnRequest(BaseModel):
force: bool = False
-def _check_token(x_admin_token: str | None) -> None:
- if not settings.scrape_admin_token:
- raise HTTPException(
- status_code=503,
- detail="admin scrape disabled — set SCRAPE_ADMIN_TOKEN",
- )
- if x_admin_token != settings.scrape_admin_token:
- raise HTTPException(status_code=401, detail="invalid admin token")
-
-
@router.post("/kn")
def trigger_kn_sweep(
payload: TriggerKnRequest,
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
- _check_token(x_admin_token)
# Defer Celery import so the API works without a broker in dev.
from app.workers.tasks.scrape_kn import (
force_release_lock,
@@ -82,7 +72,7 @@ def trigger_kn_sweep(
@router.get("/queue")
def queue_status(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Snapshot of Celery state, optimised for UI poll latency.
@@ -96,7 +86,6 @@ def queue_status(
Worst-case latency ≈ 600 ms even if no worker is reachable, vs ~8 s with
the previous 4×2 s blocking inspect calls.
"""
- _check_token(x_admin_token)
import concurrent.futures
import time
@@ -228,11 +217,10 @@ class ReleaseLockRequest(BaseModel):
@router.post("/release-lock")
def release_lock(
payload: ReleaseLockRequest,
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Принудительно удалить Redis-lock для (region, devs). Использовать когда
зомби-worker оставил lock и новые задачи скипаются с reason='lock_held'."""
- _check_token(x_admin_token)
from app.workers.tasks.scrape_kn import force_release_lock
released = force_release_lock(payload.region_code, payload.developers)
@@ -251,10 +239,9 @@ class RevokeRequest(BaseModel):
@router.post("/revoke")
def revoke_task(
payload: RevokeRequest,
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Cancel a queued or running task by id."""
- _check_token(x_admin_token)
from app.workers.celery_app import celery_app
celery_app.control.revoke(payload.task_id, terminate=payload.terminate, signal="SIGTERM")
@@ -264,12 +251,11 @@ def revoke_task(
@router.get("/failures")
def list_failures(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
run_id: int | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
"""Per-request failure log for manual browser verification."""
- _check_token(x_admin_token)
where = ""
params: dict[str, Any] = {"lim": min(limit, 200)}
if run_id is not None:
@@ -311,14 +297,13 @@ def list_failures(
@router.get("/logs")
def list_logs(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
run_id: int | None = None,
since_id: int | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Per-run progress events. Use since_id to poll incrementally:
pass the highest log_id seen → returns only newer rows."""
- _check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": min(limit, 1000)}
if run_id is not None:
@@ -369,7 +354,7 @@ def list_logs(
@router.post("/noise-sync")
def trigger_noise_sync(
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации OSM шумовых источников ЕКБ из Overpass API.
@@ -377,7 +362,6 @@ def trigger_noise_sync(
Этот endpoint — для ad-hoc запуска (например после первого деплоя или
после добавления миграции 84_osm_noise_sources_ekb).
"""
- _check_token(x_admin_token)
from app.workers.tasks.noise_sync import sync_osm_noise_sources_ekb
result = sync_osm_noise_sources_ekb.apply_async()
@@ -401,7 +385,7 @@ class HarvestQuarterRequest(BaseModel):
@router.post("/nspd/harvest-quarter")
def trigger_harvest_quarter(
payload: HarvestQuarterRequest,
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger одного quarter harvest (для testing / конкретного квартала).
@@ -409,7 +393,6 @@ def trigger_harvest_quarter(
Для получения результата — poll через Celery inspect или нождите строку
в nspd_quarter_dumps WHERE quarter_cad = payload.quarter_cad.
"""
- _check_token(x_admin_token)
from app.workers.tasks.nspd_sync import harvest_quarter
task = harvest_quarter.apply_async(
@@ -430,14 +413,13 @@ def trigger_harvest_quarter(
@router.post("/pzz-sync")
def trigger_pzz_sync(
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для импорта ПЗЗ территориальных зон ЕКБ из Росреестр PKK6.
Запускать после деплоя миграции 85_pzz_zones_ekb.sql и при необходимости
обновить данные о зонировании (обычно раз в несколько месяцев).
"""
- _check_token(x_admin_token)
from app.workers.tasks.pzz_sync import sync_pzz_zones_ekb
result = sync_pzz_zones_ekb.apply_async()
@@ -446,7 +428,7 @@ def trigger_pzz_sync(
@router.post("/poi-sync")
def trigger_poi_sync(
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Manual trigger для синхронизации OSM POI ЕКБ из Overpass API.
@@ -454,7 +436,6 @@ def trigger_poi_sync(
Этот endpoint — для ad-hoc запуска (например после первого деплоя
или при создании нового тестового участка).
"""
- _check_token(x_admin_token)
from app.workers.tasks.poi_sync import sync_osm_poi_ekb
result = sync_osm_poi_ekb.apply_async()
@@ -471,7 +452,7 @@ class TriggerObjectiveEtlRequest(BaseModel):
@router.post("/objective")
def trigger_objective_etl(
payload: TriggerObjectiveEtlRequest,
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Запустить ETL Антоновского SQLite → наша PG.
@@ -479,7 +460,6 @@ def trigger_objective_etl(
- на проде: смонтирован bind-mount-ом из /opt/gendesign/site-finder/
- локально: путь передать через payload.sqlite_path
"""
- _check_token(x_admin_token)
from app.workers.tasks.objective_etl import import_anton_objective
result = import_anton_objective.apply_async(
@@ -497,10 +477,9 @@ def trigger_objective_etl(
@router.get("/objective/runs")
def list_objective_runs(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
limit: int = 20,
) -> list[dict[str, Any]]:
- _check_token(x_admin_token)
rows = (
db.execute(
text(
@@ -556,16 +535,15 @@ class TriggerObjectiveSyncRequest(BaseModel):
@router.post("/objective/sync-our")
def trigger_objective_sync_our(
payload: TriggerObjectiveSyncRequest,
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Запустить НАШ sync_all_groups (тратит limits Объектива).
В отличие от ETL (POST /objective) — это вызовы api.objctv.ru напрямую,
с парсингом payload через app.workers.tasks.scrape_objective.
- Полезно когда нужно расширить покрытие за пределы Антоновского ЕКБ
+ Полезно когда нужно расширить покрытие за пределов Антоновского ЕКБ
(Свердл.обл целиком + Челябинск + Тюмень + Пермь).
"""
- _check_token(x_admin_token)
if not settings.objective_api_key:
raise HTTPException(
status_code=503,
@@ -606,10 +584,9 @@ class ObjectiveConfigUpdateRequest(BaseModel):
@router.get("/objective/config")
def get_objective_config(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Текущая динамическая конфигурация Objective sync (single row)."""
- _check_token(x_admin_token)
from app.services.objective_sync_config import get_config
return get_config(db).to_dict()
@@ -619,11 +596,10 @@ def get_objective_config(
def update_objective_config(
payload: ObjectiveConfigUpdateRequest,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""PATCH-style update. После изменения cron_schedule нужен restart beat,
остальные поля применяются на следующем sync-вызове автоматически."""
- _check_token(x_admin_token)
from app.services.objective_sync_config import update_config
cfg = update_config(
@@ -650,14 +626,13 @@ def update_objective_config(
@router.get("/objective/coverage")
def objective_coverage(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Что у нас в БД сейчас + что лежит в SQLite Антона (size, last modified).
Помогает понять стоит ли запускать ETL: если SQLite старше последнего
успешного run'а — данные не изменятся.
"""
- _check_token(x_admin_token)
from app.services.objective_etl import get_sqlite_info
pg_row = (
@@ -718,7 +693,7 @@ class EnqueueGeoJobRequest(BaseModel):
def enqueue_geo_job(
payload: EnqueueGeoJobRequest,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Создать bulk geo-job и поставить в очередь.
@@ -728,7 +703,6 @@ def enqueue_geo_job(
которых ещё нет в cad_quarters_geom (для region_codes если задано)
- region_bbox: TODO (для будущего spatial-bulk)
"""
- _check_token(x_admin_token)
from app.workers.tasks.nspd_geo import enqueue_geo_job as enqueue_helper
from app.workers.tasks.nspd_geo import process_nspd_geo_job
@@ -900,7 +874,7 @@ def _collect_all_in_region(
def bulk_enqueue_geo(
payload: BulkGeoEnqueueRequest,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Разбить pending cad-номера на N чанков и запустить N×len(thematic_ids) geo-jobs.
@@ -909,7 +883,6 @@ def bulk_enqueue_geo(
source=all_in_region: UNION из rosreestr_deals + cad_buildings + complexes — один
набор для всех thematic_ids (каждый thematic_id обрабатывает весь список).
"""
- _check_token(x_admin_token)
from app.services.job_settings import get_setting_value
from app.workers.tasks.nspd_geo import enqueue_geo_job as enqueue_helper
from app.workers.tasks.nspd_geo import process_nspd_geo_job
@@ -993,11 +966,10 @@ def bulk_enqueue_geo(
@router.get("/geo/jobs")
def list_geo_jobs(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
limit: int = 30,
) -> list[dict[str, Any]]:
"""Список последних geo-jobs (для UI dashboard)."""
- _check_token(x_admin_token)
rows = (
db.execute(
text(
@@ -1049,10 +1021,9 @@ def list_geo_jobs(
def cancel_geo_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Пометить job как cancelled. Worker увидит при следующей итерации."""
- _check_token(x_admin_token)
db.execute(
text(
"""
@@ -1071,10 +1042,9 @@ def cancel_geo_job(
def resume_geo_job(
job_id: int,
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
) -> dict[str, Any]:
"""Re-enqueue paused/failed job. Resume idempotent через pending targets."""
- _check_token(x_admin_token)
from app.services.job_settings import get_setting_value
from app.workers.tasks.nspd_geo import process_nspd_geo_job
@@ -1094,7 +1064,7 @@ def resume_geo_job(
@router.get("/all/runs")
def list_all_runs(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
scraper_type: str | None = None,
limit: int = 30,
) -> list[dict[str, Any]]:
@@ -1103,7 +1073,6 @@ def list_all_runs(
Поверх v_scrape_runs_unified. Если scraper_type не задан — все 3.
Используется главной dashboard /admin/scrape/all.
"""
- _check_token(x_admin_token)
where = "" if not scraper_type else "WHERE scraper_type = :st"
params: dict[str, Any] = {"lim": min(limit, 200)}
if scraper_type:
@@ -1153,13 +1122,12 @@ def list_all_runs(
@router.get("/all/logs")
def list_all_logs(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
scraper_type: str | None = None,
run_id: int | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
- _check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": min(limit, 1000)}
if scraper_type:
@@ -1206,10 +1174,9 @@ def list_all_logs(
@router.get("/runs")
def list_runs(
db: Annotated[Session, Depends(get_db)],
- x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+ _: AdminTokenAuth,
limit: int = 20,
) -> list[dict[str, Any]]:
- _check_token(x_admin_token)
rows = (
db.execute(
text(
diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py
new file mode 100644
index 00000000..75fd3b06
--- /dev/null
+++ b/backend/app/core/deps.py
@@ -0,0 +1,23 @@
+"""Shared FastAPI dependencies."""
+
+from typing import Annotated
+
+from fastapi import Depends, Header, HTTPException, status
+
+from app.core.config import settings
+
+
+def verify_admin_token(
+ x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
+) -> None:
+ """Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
+ if not settings.scrape_admin_token:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
+ )
+ if x_admin_token != settings.scrape_admin_token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
+
+
+AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
--
2.45.3