kin: BATON-008-backend_dev

This commit is contained in:
Gros Frumos 2026-03-21 09:19:50 +02:00
parent e21bcb1eb4
commit 4c9fec17de
11 changed files with 651 additions and 4 deletions

View file

@ -22,3 +22,7 @@ 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")
ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584")
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")

View file

@ -67,6 +67,23 @@ async def init_db() -> None:
count INTEGER NOT NULL DEFAULT 0,
window_start REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
login TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
push_subscription TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_registrations_status
ON registrations(status);
CREATE INDEX IF NOT EXISTS idx_registrations_email
ON registrations(email);
CREATE INDEX IF NOT EXISTS idx_registrations_login
ON registrations(login);
""")
# Migrations for existing databases (silently ignore if columns already exist)
for stmt in [
@ -284,6 +301,56 @@ async def rate_limit_increment(key: str, window: float) -> int:
return row["count"] if row else 1
async def create_registration(
email: str,
login: str,
password_hash: str,
push_subscription: Optional[str] = None,
) -> int:
"""Insert a new registration. Raises aiosqlite.IntegrityError on email/login conflict."""
async with _get_conn() as conn:
async with conn.execute(
"""
INSERT INTO registrations (email, login, password_hash, push_subscription)
VALUES (?, ?, ?, ?)
""",
(email, login, password_hash, push_subscription),
) as cur:
reg_id = cur.lastrowid
await conn.commit()
return reg_id # type: ignore[return-value]
async def get_registration(reg_id: int) -> Optional[dict]:
async with _get_conn() as conn:
async with conn.execute(
"SELECT id, email, login, status, push_subscription, created_at FROM registrations WHERE id = ?",
(reg_id,),
) as cur:
row = await cur.fetchone()
if row is None:
return None
return {
"id": row["id"],
"email": row["email"],
"login": row["login"],
"status": row["status"],
"push_subscription": row["push_subscription"],
"created_at": row["created_at"],
}
async def update_registration_status(reg_id: int, status: str) -> bool:
async with _get_conn() as conn:
async with conn.execute(
"UPDATE registrations SET status = ? WHERE id = ?",
(status, reg_id),
) as cur:
changed = cur.rowcount > 0
await conn.commit()
return changed
async def save_telegram_batch(
message_text: str,
signals_count: int,

View file

@ -15,12 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, telegram
from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
from backend import config, db, push, telegram
from backend.middleware import rate_limit_auth_register, rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
from backend.models import (
AdminBlockRequest,
AdminCreateUserRequest,
AdminSetPasswordRequest,
AuthRegisterRequest,
AuthRegisterResponse,
RegisterRequest,
RegisterResponse,
SignalRequest,
@ -182,6 +184,84 @@ async def signal(
return SignalResponse(status="ok", signal_id=signal_id)
@app.post("/api/auth/register", response_model=AuthRegisterResponse, status_code=201)
async def auth_register(
body: AuthRegisterRequest,
_: None = Depends(rate_limit_auth_register),
) -> AuthRegisterResponse:
password_hash = _hash_password(body.password)
push_sub_json = (
body.push_subscription.model_dump_json() if body.push_subscription else None
)
try:
reg_id = await db.create_registration(
email=str(body.email),
login=body.login,
password_hash=password_hash,
push_subscription=push_sub_json,
)
except Exception as exc:
# aiosqlite.IntegrityError on email/login UNIQUE conflict
if "UNIQUE" in str(exc) or "unique" in str(exc).lower():
raise HTTPException(status_code=409, detail="Email или логин уже существует")
raise
reg = await db.get_registration(reg_id)
asyncio.create_task(
telegram.send_registration_notification(
reg_id=reg_id,
login=body.login,
email=str(body.email),
created_at=reg["created_at"] if reg else "",
)
)
return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение")
async def _handle_callback_query(cb: dict) -> None:
"""Process approve/reject callback from admin Telegram inline buttons."""
data = cb.get("data", "")
callback_query_id = cb.get("id", "")
message = cb.get("message", {})
chat_id = message.get("chat", {}).get("id")
message_id = message.get("message_id")
if ":" not in data:
return
action, reg_id_str = data.split(":", 1)
try:
reg_id = int(reg_id_str)
except ValueError:
return
reg = await db.get_registration(reg_id)
if reg is None:
await telegram.answer_callback_query(callback_query_id)
return
if action == "approve":
await db.update_registration_status(reg_id, "approved")
if chat_id and message_id:
await telegram.edit_message_text(
chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен"
)
if reg["push_subscription"]:
asyncio.create_task(
push.send_push(
reg["push_subscription"],
"Baton",
"Ваша регистрация одобрена!",
)
)
elif action == "reject":
await db.update_registration_status(reg_id, "rejected")
if chat_id and message_id:
await telegram.edit_message_text(
chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён"
)
await telegram.answer_callback_query(callback_query_id)
@app.get("/admin/users", dependencies=[Depends(verify_admin_token)])
async def admin_list_users() -> list[dict]:
return await db.admin_list_users()
@ -226,6 +306,13 @@ async def webhook_telegram(
_: None = Depends(verify_webhook_secret),
) -> dict[str, Any]:
update = await request.json()
# Handle inline button callback queries (approve/reject registration)
callback_query = update.get("callback_query")
if callback_query:
await _handle_callback_query(callback_query)
return {"ok": True}
message = update.get("message", {})
text = message.get("text", "")

View file

@ -16,6 +16,9 @@ _RATE_WINDOW = 600 # 10 minutes
_SIGNAL_RATE_LIMIT = 10
_SIGNAL_RATE_WINDOW = 60 # 1 minute
_AUTH_REGISTER_RATE_LIMIT = 3
_AUTH_REGISTER_RATE_WINDOW = 600 # 10 minutes
def _get_client_ip(request: Request) -> str:
return (
@ -55,3 +58,10 @@ async def rate_limit_signal(request: Request) -> None:
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
if count > _SIGNAL_RATE_LIMIT:
raise HTTPException(status_code=429, detail="Too Many Requests")
async def rate_limit_auth_register(request: Request) -> None:
key = f"authreg:{_get_client_ip(request)}"
count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW)
if count > _AUTH_REGISTER_RATE_LIMIT:
raise HTTPException(status_code=429, detail="Too Many Requests")

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
@ -44,3 +44,25 @@ class AdminSetPasswordRequest(BaseModel):
class AdminBlockRequest(BaseModel):
is_blocked: bool
class PushSubscriptionKeys(BaseModel):
p256dh: str
auth: str
class PushSubscription(BaseModel):
endpoint: str
keys: PushSubscriptionKeys
class AuthRegisterRequest(BaseModel):
email: EmailStr
login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$')
password: str = Field(..., min_length=8, max_length=128)
push_subscription: Optional[PushSubscription] = None
class AuthRegisterResponse(BaseModel):
status: str
message: str

35
backend/push.py Normal file
View file

@ -0,0 +1,35 @@
from __future__ import annotations
import asyncio
import json
import logging
from backend import config
logger = logging.getLogger(__name__)
async def send_push(subscription_json: str, title: str, body: str) -> None:
"""Send a Web Push notification. Swallows all errors — never raises."""
if not config.VAPID_PRIVATE_KEY:
logger.warning("VAPID_PRIVATE_KEY not configured — push notification skipped")
return
try:
import pywebpush # type: ignore[import]
except ImportError:
logger.warning("pywebpush not installed — push notification skipped")
return
try:
subscription_info = json.loads(subscription_json)
data = json.dumps({"title": title, "body": body})
vapid_claims = {"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL or 'admin@example.com'}"}
await asyncio.to_thread(
pywebpush.webpush,
subscription_info=subscription_info,
data=data,
vapid_private_key=config.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims,
)
except Exception as exc:
logger.error("Web Push failed: %s", exc)

View file

@ -55,6 +55,68 @@ async def send_message(text: str) -> None:
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
async def send_registration_notification(
reg_id: int, login: str, email: str, created_at: str
) -> None:
"""Send registration request notification to admin with approve/reject inline buttons.
Swallows all errors never raises."""
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
text = (
f"📋 Новая заявка на регистрацию\n\n"
f"Login: {login}\nEmail: {email}\nДата: {created_at}"
)
reply_markup = {
"inline_keyboard": [[
{"text": "✅ Одобрить", "callback_data": f"approve:{reg_id}"},
{"text": "❌ Отклонить", "callback_data": f"reject:{reg_id}"},
]]
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
url,
json={
"chat_id": config.ADMIN_CHAT_ID,
"text": text,
"reply_markup": reply_markup,
},
)
if resp.status_code != 200:
logger.error(
"send_registration_notification failed %s: %s",
resp.status_code,
resp.text,
)
except Exception as exc:
logger.error("send_registration_notification error: %s", exc)
async def answer_callback_query(callback_query_id: str) -> None:
"""Answer a Telegram callback query. Swallows all errors."""
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="answerCallbackQuery")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json={"callback_query_id": callback_query_id})
if resp.status_code != 200:
logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text)
except Exception as exc:
logger.error("answerCallbackQuery error: %s", exc)
async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None:
"""Edit a Telegram message text. Swallows all errors."""
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="editMessageText")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
url, json={"chat_id": chat_id, "message_id": message_id, "text": text}
)
if resp.status_code != 200:
logger.error("editMessageText failed %s: %s", resp.status_code, resp.text)
except Exception as exc:
logger.error("editMessageText error: %s", exc)
async def set_webhook(url: str, secret: str) -> None:
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
async with httpx.AsyncClient(timeout=10) as client: