Files
remna/app/routers/guest.py
T
2026-07-25 00:37:09 +03:00

273 lines
8.9 KiB
Python

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,
},
)