Merge branch 'BATON-005-backend_dev'
This commit is contained in:
commit
e547e1ce09
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"
|
WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
||||||
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
||||||
|
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
|
||||||
|
|
|
||||||
124
backend/db.py
124
backend/db.py
|
|
@ -27,6 +27,8 @@ async def init_db() -> None:
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT UNIQUE NOT NULL,
|
uuid TEXT UNIQUE NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
password_hash TEXT DEFAULT NULL,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -58,6 +60,16 @@ async def init_db() -> None:
|
||||||
CREATE INDEX IF NOT EXISTS idx_batches_status
|
CREATE INDEX IF NOT EXISTS idx_batches_status
|
||||||
ON telegram_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()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,6 +116,118 @@ async def get_user_name(uuid: str) -> Optional[str]:
|
||||||
return row["name"] if row else None
|
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(
|
async def save_telegram_batch(
|
||||||
message_text: str,
|
message_text: str,
|
||||||
signals_count: int,
|
signals_count: int,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from backend import config, db, telegram
|
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 (
|
from backend.models import (
|
||||||
|
AdminBlockRequest,
|
||||||
|
AdminCreateUserRequest,
|
||||||
|
AdminSetPasswordRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
SignalRequest,
|
SignalRequest,
|
||||||
|
|
@ -24,6 +29,16 @@ from backend.models import (
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
# aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
|
||||||
|
|
||||||
_KEEPALIVE_INTERVAL = 600 # 10 минут
|
_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)
|
@app.post("/api/signal", response_model=SignalResponse)
|
||||||
async def signal(body: SignalRequest) -> 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
|
geo = body.geo
|
||||||
lat = geo.lat if geo else None
|
lat = geo.lat if geo else None
|
||||||
lon = geo.lon 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)
|
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")
|
@app.post("/api/webhook/telegram")
|
||||||
async def webhook_telegram(
|
async def webhook_telegram(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
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
|
from backend import config
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
_RATE_LIMIT = 5
|
_RATE_LIMIT = 5
|
||||||
_RATE_WINDOW = 600 # 10 minutes
|
_RATE_WINDOW = 600 # 10 minutes
|
||||||
|
|
||||||
|
|
@ -20,6 +24,15 @@ async def verify_webhook_secret(
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
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:
|
async def rate_limit_register(request: Request) -> None:
|
||||||
counters = request.app.state.rate_counters
|
counters = request.app.state.rate_counters
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,17 @@ class SignalRequest(BaseModel):
|
||||||
class SignalResponse(BaseModel):
|
class SignalResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
signal_id: int
|
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
|
FRONTEND_ORIGIN=https://baton.itafrika.com
|
||||||
APP_URL=https://baton.itafrika.com
|
APP_URL=https://baton.itafrika.com
|
||||||
DB_PATH=/opt/baton/baton.db
|
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_SECRET", "test-webhook-secret")
|
||||||
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||||
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||||
|
|
||||||
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
# ── 2. aiosqlite monkey-patch ────────────────────────────────────────────────
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue