kin: BATON-005-backend_dev
This commit is contained in:
parent
68a1c90541
commit
cb95c9928f
7 changed files with 219 additions and 7 deletions
|
|
@ -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")
|
||||
|
|
|
|||
132
backend/db.py
132
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,
|
||||
|
|
|
|||
|
|
@ -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_hex>:<dk_hex>``
|
||||
"""
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue