273 lines
8.9 KiB
Python
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,
|
|
},
|
|
)
|