from __future__ import annotations from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from config import get_settings from database import get_db from remnawave import RemnawaveClient, RemnawaveError router = APIRouter(tags=["guest"]) templates = Jinja2Templates(directory="templates") def _now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def _parse_dt(value: str | None) -> datetime | None: if not value: return None raw = value.strip() if "T" not in raw and " " in raw: raw = raw.replace(" ", "T") if raw.endswith("Z"): raw = raw[:-1] + "+00:00" try: dt = datetime.fromisoformat(raw) except ValueError: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt async def _load_link(token: str) -> dict | None: db = await get_db() try: cursor = await db.execute( "SELECT * FROM guest_links WHERE token = ?", (token,) ) row = await cursor.fetchone() return dict(row) if row else None finally: await db.close() def _link_status(link: dict) -> str | None: if not link["is_active"]: return "Ссылка отключена администратором." if link["used_count"] >= link["max_uses"]: return "Лимит активаций исчерпан." expires = _parse_dt(link.get("expires_at")) if expires and datetime.now(timezone.utc) > expires: return "Срок действия ссылки истёк." return None @router.get("/g/{token}", response_class=HTMLResponse) async def guest_page(request: Request, token: str): settings = get_settings() link = await _load_link(token) if not link: return templates.TemplateResponse( "guest/invalid.html", {"request": request, "brand": settings.brand_name, "message": "Ссылка не найдена."}, status_code=404, ) error = _link_status(link) return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": error, "success": None, "blocked": error is not None, }, ) @router.post("/g/{token}", response_class=HTMLResponse) async def guest_claim( request: Request, token: str, identifier: str = Form(...), ): settings = get_settings() link = await _load_link(token) if not link: return templates.TemplateResponse( "guest/invalid.html", {"request": request, "brand": settings.brand_name, "message": "Ссылка не найдена."}, status_code=404, ) blocked = _link_status(link) if blocked: return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": blocked, "success": None, "blocked": True, }, status_code=400, ) if link["link_type"] == "traffic" and int(link.get("duration_days") or 0) < 1: return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": "У ссылки не задан срок трафика. Создайте новую ссылку с числом дней.", "success": None, "blocked": True, }, status_code=400, ) if link["link_type"] == "extend" and int(link.get("plan_traffic_gb") or 0) < 1: return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": "У ссылки не задан трафик тарифа. Создайте новую ссылку продления с ГБ.", "success": None, "blocked": True, }, status_code=400, ) client = RemnawaveClient() grant_expires: str | None = None try: if link["link_type"] == "extend": plan_gb = int(link["plan_traffic_gb"]) result = await client.extend_subscription( identifier, link["value"], plan_gb ) detail = ( f"Продлено на {result['days']} дн. " f"Трафик тарифа: {result['old_limit_gb']} → {result['new_limit_gb']} ГБ " f"(сброшен счётчик). Новая дата: {result['new_expire'][:19]}" ) else: duration = int(link["duration_days"]) result = await client.add_traffic(identifier, link["value"]) grant_expires_dt = datetime.now(timezone.utc) + timedelta(days=duration) grant_expires = grant_expires_dt.replace(microsecond=0).isoformat() detail = ( f"+{result['added_gb']} ГБ на {duration} дн. " f"(до {grant_expires[:19]} UTC). " f"Лимит: {result['old_limit_gb']} → {result['new_limit_gb']} ГБ" ) except RemnawaveError as exc: return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": str(exc), "success": None, "blocked": False, }, status_code=400, ) db = await get_db() try: await db.execute("BEGIN IMMEDIATE") cursor = await db.execute( "SELECT used_count, max_uses, is_active FROM guest_links WHERE id = ?", (link["id"],), ) fresh = await cursor.fetchone() if ( not fresh or not fresh["is_active"] or fresh["used_count"] >= fresh["max_uses"] ): await db.execute("ROLLBACK") return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": "Ссылка уже недоступна.", "success": None, "blocked": True, }, status_code=400, ) cursor = await db.execute( """ INSERT INTO guest_claims (link_id, username, user_uuid, detail) VALUES (?, ?, ?, ?) """, ( link["id"], result.get("username") or identifier.strip(), result.get("uuid"), detail, ), ) claim_id = cursor.lastrowid if link["link_type"] == "extend" and result.get("uuid"): # Лимит тарифа уже выставлен — старые временные бонусы не откатываем повторно await db.execute( """ UPDATE traffic_grants SET status = 'reverted', reverted_at = ?, last_error = 'cancelled by plan renew' WHERE user_uuid = ? AND status = 'active' """, (_now_iso(), result["uuid"]), ) if link["link_type"] == "traffic" and grant_expires and result.get("uuid"): await db.execute( """ INSERT INTO traffic_grants (claim_id, link_id, user_uuid, username, bytes_added, expires_at, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'active', ?) """, ( claim_id, link["id"], result["uuid"], result.get("username") or identifier.strip(), int(result["bytes_added"]), grant_expires, _now_iso(), ), ) await db.execute( "UPDATE guest_links SET used_count = used_count + 1 WHERE id = ?", (link["id"],), ) await db.commit() link["used_count"] += 1 finally: await db.close() return templates.TemplateResponse( "guest/claim.html", { "request": request, "brand": settings.brand_name, "link": link, "error": None, "success": detail, "blocked": _link_status(link) is not None, }, )