From cb95c9928fc46b315e47d7d87a2aed4ebfd4f93a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:39:28 +0200 Subject: [PATCH] kin: BATON-005-backend_dev --- backend/config.py | 1 + backend/db.py | 132 ++++++++++++++++++++++++++++++++++++++++-- backend/main.py | 60 ++++++++++++++++++- backend/middleware.py | 15 ++++- backend/models.py | 14 +++++ deploy/env.template | 3 + tests/conftest.py | 1 + 7 files changed, 219 insertions(+), 7 deletions(-) diff --git a/backend/config.py b/backend/config.py index af4d933..40159b0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -21,3 +21,4 @@ WEBHOOK_URL: str = _require("WEBHOOK_URL") WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true" FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping +ADMIN_TOKEN: str = _require("ADMIN_TOKEN") diff --git a/backend/db.py b/backend/db.py index e52a95f..e0aca18 100644 --- a/backend/db.py +++ b/backend/db.py @@ -24,10 +24,12 @@ async def init_db() -> None: async with _get_conn() as conn: await conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + is_blocked INTEGER NOT NULL DEFAULT 0, + password_hash TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS signals ( @@ -58,6 +60,16 @@ async def init_db() -> None: CREATE INDEX IF NOT EXISTS idx_batches_status ON telegram_batches(status); """) + # Migrations for existing databases (silently ignore if columns already exist) + for stmt in [ + "ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN password_hash TEXT DEFAULT NULL", + ]: + try: + await conn.execute(stmt) + await conn.commit() + except Exception: + pass # Column already exists await conn.commit() @@ -104,6 +116,118 @@ async def get_user_name(uuid: str) -> Optional[str]: return row["name"] if row else None +async def is_user_blocked(uuid: str) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "SELECT is_blocked FROM users WHERE uuid = ?", (uuid,) + ) as cur: + row = await cur.fetchone() + return bool(row["is_blocked"]) if row else False + + +async def admin_list_users() -> list[dict]: + async with _get_conn() as conn: + async with conn.execute( + "SELECT id, uuid, name, is_blocked, created_at FROM users ORDER BY id" + ) as cur: + rows = await cur.fetchall() + return [ + { + "id": row["id"], + "uuid": row["uuid"], + "name": row["name"], + "is_blocked": bool(row["is_blocked"]), + "created_at": row["created_at"], + } + for row in rows + ] + + +async def admin_get_user_by_id(user_id: int) -> Optional[dict]: + async with _get_conn() as conn: + async with conn.execute( + "SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?", + (user_id,), + ) as cur: + row = await cur.fetchone() + if row is None: + return None + return { + "id": row["id"], + "uuid": row["uuid"], + "name": row["name"], + "is_blocked": bool(row["is_blocked"]), + "created_at": row["created_at"], + } + + +async def admin_create_user( + uuid: str, name: str, password_hash: Optional[str] = None +) -> Optional[dict]: + """Returns None if UUID already exists.""" + async with _get_conn() as conn: + try: + async with conn.execute( + "INSERT INTO users (uuid, name, password_hash) VALUES (?, ?, ?)", + (uuid, name, password_hash), + ) as cur: + new_id = cur.lastrowid + except Exception: + return None # UNIQUE constraint violation — UUID already exists + await conn.commit() + async with conn.execute( + "SELECT id, uuid, name, is_blocked, created_at FROM users WHERE id = ?", + (new_id,), + ) as cur: + row = await cur.fetchone() + return { + "id": row["id"], + "uuid": row["uuid"], + "name": row["name"], + "is_blocked": bool(row["is_blocked"]), + "created_at": row["created_at"], + } + + +async def admin_set_password(user_id: int, password_hash: str) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (password_hash, user_id), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + +async def admin_set_blocked(user_id: int, is_blocked: bool) -> bool: + async with _get_conn() as conn: + async with conn.execute( + "UPDATE users SET is_blocked = ? WHERE id = ?", + (1 if is_blocked else 0, user_id), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + +async def admin_delete_user(user_id: int) -> bool: + async with _get_conn() as conn: + # Delete signals first (no FK cascade in SQLite by default) + async with conn.execute( + "DELETE FROM signals WHERE user_uuid = (SELECT uuid FROM users WHERE id = ?)", + (user_id,), + ): + pass + async with conn.execute( + "DELETE FROM users WHERE id = ?", + (user_id,), + ) as cur: + changed = cur.rowcount > 0 + await conn.commit() + return changed + + async def save_telegram_batch( message_text: str, signals_count: int, diff --git a/backend/main.py b/backend/main.py index 025d69d..b7388cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,20 +1,25 @@ from __future__ import annotations import asyncio +import hashlib import logging +import os import time from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any import httpx -from fastapi import Depends, FastAPI, Request +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from backend import config, db, telegram -from backend.middleware import rate_limit_register, verify_webhook_secret +from backend.middleware import rate_limit_register, verify_admin_token, verify_webhook_secret from backend.models import ( + AdminBlockRequest, + AdminCreateUserRequest, + AdminSetPasswordRequest, RegisterRequest, RegisterResponse, SignalRequest, @@ -24,6 +29,16 @@ from backend.models import ( logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) + +def _hash_password(password: str) -> str: + """Hash a password using PBKDF2-HMAC-SHA256 (stdlib, no external deps). + + Stored format: ``:`` + """ + salt = os.urandom(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 260_000) + return f"{salt.hex()}:{dk.hex()}" + # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут @@ -108,6 +123,9 @@ async def register(body: RegisterRequest, _: None = Depends(rate_limit_register) @app.post("/api/signal", response_model=SignalResponse) async def signal(body: SignalRequest) -> SignalResponse: + if await db.is_user_blocked(body.user_id): + raise HTTPException(status_code=403, detail="User is blocked") + geo = body.geo lat = geo.lat if geo else None lon = geo.lon if geo else None @@ -139,6 +157,44 @@ async def signal(body: SignalRequest) -> SignalResponse: return SignalResponse(status="ok", signal_id=signal_id) +@app.get("/admin/users", dependencies=[Depends(verify_admin_token)]) +async def admin_list_users() -> list[dict]: + return await db.admin_list_users() + + +@app.post("/admin/users", status_code=201, dependencies=[Depends(verify_admin_token)]) +async def admin_create_user(body: AdminCreateUserRequest) -> dict: + password_hash = _hash_password(body.password) if body.password else None + result = await db.admin_create_user(body.uuid, body.name, password_hash) + if result is None: + raise HTTPException(status_code=409, detail="User with this UUID already exists") + return result + + +@app.put("/admin/users/{user_id}/password", dependencies=[Depends(verify_admin_token)]) +async def admin_set_password(user_id: int, body: AdminSetPasswordRequest) -> dict: + changed = await db.admin_set_password(user_id, _hash_password(body.password)) + if not changed: + raise HTTPException(status_code=404, detail="User not found") + return {"ok": True} + + +@app.put("/admin/users/{user_id}/block", dependencies=[Depends(verify_admin_token)]) +async def admin_block_user(user_id: int, body: AdminBlockRequest) -> dict: + changed = await db.admin_set_blocked(user_id, body.is_blocked) + if not changed: + raise HTTPException(status_code=404, detail="User not found") + user = await db.admin_get_user_by_id(user_id) + return user # type: ignore[return-value] + + +@app.delete("/admin/users/{user_id}", status_code=204, dependencies=[Depends(verify_admin_token)]) +async def admin_delete_user(user_id: int) -> None: + deleted = await db.admin_delete_user(user_id) + if not deleted: + raise HTTPException(status_code=404, detail="User not found") + + @app.post("/api/webhook/telegram") async def webhook_telegram( request: Request, diff --git a/backend/middleware.py b/backend/middleware.py index 34d913e..a384c84 100644 --- a/backend/middleware.py +++ b/backend/middleware.py @@ -2,11 +2,15 @@ from __future__ import annotations import secrets import time +from typing import Optional -from fastapi import Header, HTTPException, Request +from fastapi import Depends, Header, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config +_bearer = HTTPBearer(auto_error=False) + _RATE_LIMIT = 5 _RATE_WINDOW = 600 # 10 minutes @@ -20,6 +24,15 @@ async def verify_webhook_secret( raise HTTPException(status_code=403, detail="Forbidden") +async def verify_admin_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> None: + if credentials is None or not secrets.compare_digest( + credentials.credentials, config.ADMIN_TOKEN + ): + raise HTTPException(status_code=401, detail="Unauthorized") + + async def rate_limit_register(request: Request) -> None: counters = request.app.state.rate_counters client_ip = request.client.host if request.client else "unknown" diff --git a/backend/models.py b/backend/models.py index 68265de..b89e884 100644 --- a/backend/models.py +++ b/backend/models.py @@ -29,3 +29,17 @@ class SignalRequest(BaseModel): class SignalResponse(BaseModel): status: str signal_id: int + + +class AdminCreateUserRequest(BaseModel): + uuid: str = Field(..., min_length=1) + name: str = Field(..., min_length=1, max_length=100) + password: Optional[str] = None + + +class AdminSetPasswordRequest(BaseModel): + password: str = Field(..., min_length=1) + + +class AdminBlockRequest(BaseModel): + is_blocked: bool diff --git a/deploy/env.template b/deploy/env.template index abc87f3..ec9d2ef 100644 --- a/deploy/env.template +++ b/deploy/env.template @@ -17,3 +17,6 @@ WEBHOOK_ENABLED=true FRONTEND_ORIGIN=https://baton.itafrika.com APP_URL=https://baton.itafrika.com DB_PATH=/opt/baton/baton.db + +# Admin API token — случайная строка 32+ символа (сгенерировать: openssl rand -hex 32) +ADMIN_TOKEN= diff --git a/tests/conftest.py b/tests/conftest.py index 2604da8..24b0ff3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ os.environ.setdefault("CHAT_ID", "-1001234567890") os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") +os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") # ── 2. aiosqlite monkey-patch ──────────────────────────────────────────────── import aiosqlite