first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from auth import check_admin_credentials, get_current_admin, require_admin_page
|
||||
from config import get_settings
|
||||
from database import get_db
|
||||
from urlutil import public_base_url
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
if require_admin_page(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
settings = get_settings()
|
||||
return templates.TemplateResponse(
|
||||
"admin/login.html",
|
||||
{"request": request, "brand": settings.brand_name, "error": None},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
settings = get_settings()
|
||||
if not check_admin_credentials(username, password):
|
||||
return templates.TemplateResponse(
|
||||
"admin/login.html",
|
||||
{
|
||||
"request": request,
|
||||
"brand": settings.brand_name,
|
||||
"error": "Неверный логин или пароль",
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
request.session["admin"] = username
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/admin/login", status_code=303)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request):
|
||||
if not require_admin_page(request):
|
||||
return RedirectResponse("/admin/login", status_code=303)
|
||||
|
||||
settings = get_settings()
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT l.*,
|
||||
(SELECT COUNT(*) FROM guest_claims c WHERE c.link_id = l.id) AS claims
|
||||
FROM guest_links l
|
||||
ORDER BY l.id DESC
|
||||
"""
|
||||
)
|
||||
links = [dict(row) for row in await cursor.fetchall()]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
site_base = public_base_url(request)
|
||||
return templates.TemplateResponse(
|
||||
"admin/dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"brand": settings.brand_name,
|
||||
"links": links,
|
||||
"site_base": site_base,
|
||||
"flash": request.query_params.get("flash"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/links")
|
||||
async def create_link(
|
||||
request: Request,
|
||||
link_type: str = Form(...),
|
||||
title: str = Form(""),
|
||||
value: int = Form(...),
|
||||
max_uses: int = Form(1),
|
||||
duration_days: int = Form(0),
|
||||
plan_traffic_gb: int = Form(0),
|
||||
expires_at: str = Form(""),
|
||||
note: str = Form(""),
|
||||
_admin: str = Depends(get_current_admin),
|
||||
):
|
||||
if link_type not in ("extend", "traffic"):
|
||||
raise HTTPException(400, "Неверный тип ссылки")
|
||||
if value < 1:
|
||||
raise HTTPException(400, "Значение должно быть >= 1")
|
||||
if max_uses < 1:
|
||||
raise HTTPException(400, "max_uses должен быть >= 1")
|
||||
|
||||
if link_type == "traffic":
|
||||
if duration_days < 1:
|
||||
raise HTTPException(
|
||||
400, "Для трафика укажите срок действия (дней) — трафик временный"
|
||||
)
|
||||
plan_traffic_gb = 0
|
||||
else:
|
||||
duration_days = 0
|
||||
if plan_traffic_gb < 1:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Для продления укажите трафик тарифа (ГБ) — лимит зафиксируется при активации",
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(12)
|
||||
expires = expires_at.strip() or None
|
||||
if not title.strip():
|
||||
if link_type == "extend":
|
||||
title = f"Продление +{value} дн. / {plan_traffic_gb} ГБ"
|
||||
else:
|
||||
title = f"Трафик +{value} ГБ на {duration_days} дн."
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO guest_links
|
||||
(token, link_type, title, value, max_uses, expires_at, note,
|
||||
created_at, duration_days, plan_traffic_gb)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
token,
|
||||
link_type,
|
||||
title.strip(),
|
||||
value,
|
||||
max_uses,
|
||||
expires,
|
||||
note.strip(),
|
||||
_now_iso(),
|
||||
duration_days,
|
||||
plan_traffic_gb,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
return RedirectResponse("/admin?flash=created", status_code=303)
|
||||
|
||||
|
||||
@router.post("/links/{link_id}/toggle")
|
||||
async def toggle_link(link_id: int, _admin: str = Depends(get_current_admin)):
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute(
|
||||
"UPDATE guest_links SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END WHERE id = ?",
|
||||
(link_id,),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
return RedirectResponse("/admin?flash=toggled", status_code=303)
|
||||
|
||||
|
||||
@router.post("/links/{link_id}/delete")
|
||||
async def delete_link(link_id: int, _admin: str = Depends(get_current_admin)):
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute("DELETE FROM guest_links WHERE id = ?", (link_id,))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
return RedirectResponse("/admin?flash=deleted", status_code=303)
|
||||
|
||||
|
||||
@router.get("/links/{link_id}", response_class=HTMLResponse)
|
||||
async def link_detail(request: Request, link_id: int):
|
||||
if not require_admin_page(request):
|
||||
return RedirectResponse("/admin/login", status_code=303)
|
||||
|
||||
settings = get_settings()
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute("SELECT * FROM guest_links WHERE id = ?", (link_id,))
|
||||
link = await cursor.fetchone()
|
||||
if not link:
|
||||
raise HTTPException(404, "Ссылка не найдена")
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM guest_claims WHERE link_id = ? ORDER BY id DESC",
|
||||
(link_id,),
|
||||
)
|
||||
claims = [dict(row) for row in await cursor.fetchall()]
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT * FROM traffic_grants
|
||||
WHERE link_id = ?
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
(link_id,),
|
||||
)
|
||||
grants = [dict(row) for row in await cursor.fetchall()]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"admin/link_detail.html",
|
||||
{
|
||||
"request": request,
|
||||
"brand": settings.brand_name,
|
||||
"link": dict(link),
|
||||
"claims": claims,
|
||||
"grants": grants,
|
||||
"site_base": public_base_url(request),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
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,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from config import get_settings
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def landing(request: Request):
|
||||
settings = get_settings()
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"brand": settings.brand_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user