first commit

This commit is contained in:
orohi
2026-07-25 00:37:09 +03:00
commit 3ca7f9f7cc
35 changed files with 2478 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Package marker
+229
View File
@@ -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),
},
)
+272
View File
@@ -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,
},
)
+25
View File
@@ -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"}