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
+16
View File
@@ -0,0 +1,16 @@
### Dokploy — домен только в панели Domains ###
BRAND_NAME=REMNA
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_strong_password
SECRET_KEY=change_me_to_random_64_chars_here
### Remnawave API ###
REMNAWAVE_BASE_URL=https://ВАШ_ДОМЕН_ПАНЕЛИ
REMNAWAVE_TOKEN=your_api_token_here
### Опционально: принудительный URL ссылок (обычно не нужно) ###
# SITE_PUBLIC_URL=https://example.com
### Только для docker-compose.hub.yml ###
DOCKERHUB_USER=YOUR_DOCKERHUB_USER
IMAGE_TAG=latest
+10
View File
@@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
.venv/
venv/
data/
*.db
.DS_Store
*.zip
+39
View File
@@ -0,0 +1,39 @@
# Деплой в Dokploy
## Тип сервиса
**Docker Compose** (не Nixpacks Application).
Или Application с **Dockerfile** в корне.
---
## Environment (без домена)
Домен **не** прописывайте в env — только в **Domains**.
| Переменная | |
|---|---|
| `ADMIN_USERNAME` | логин админки |
| `ADMIN_PASSWORD` | пароль |
| `SECRET_KEY` | `openssl rand -hex 32` |
| `REMNAWAVE_BASE_URL` | URL вашей панели Remnawave |
| `REMNAWAVE_TOKEN` | API token |
| `BRAND_NAME` | опционально, по умолчанию REMNA |
Гостевые ссылки в админке копируются с текущего Host (тот домен, что открыли в браузере).
---
## Domains (здесь указываете домен)
1. **Domains** → Add
2. Host: ваш домен
3. Service: **web**
4. Port: **8000**
5. HTTPS + Let's Encrypt
---
## Deploy
Deploy → https://ваш-домен/admin
+9
View File
@@ -0,0 +1,9 @@
# Docker Hub → быстрый pull в Dokploy
```bash
docker login
docker build -t ВАШ_ЛОГИН/remna-site:latest ./app
docker push ВАШ_ЛОГИН/remna-site:latest
```
В Dokploy: compose file `docker-compose.hub.yml`, env `DOCKERHUB_USER=ВАШ_ЛОГИН`, затем Deploy.
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+12
View File
@@ -0,0 +1,12 @@
# Remna Site (Dokploy)
**Домен** — только в Dokploy → Domains (web, port 8000).
В env домен не нужен.
```text
Docker Compose → docker-compose.yml
Env → ADMIN_*, SECRET_KEY, REMNAWAVE_*
Domains → ваш домен → web:8000 → HTTPS
```
Инструкция: [DEPLOY.md](DEPLOY.md)
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Request, status
from passlib.context import CryptContext
from config import get_settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def check_admin_credentials(username: str, password: str) -> bool:
settings = get_settings()
return username == settings.admin_username and password == settings.admin_password
def get_current_admin(request: Request) -> str:
user = request.session.get("admin")
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется авторизация",
)
return user
def require_admin_page(request: Request) -> str | None:
return request.session.get("admin")
+24
View File
@@ -0,0 +1,24 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
brand_name: str = "REMNA"
# Опционально: если задано — принудительный URL для гостевых ссылок.
# Иначе берётся из запроса (домен из Dokploy Domains).
site_public_url: str = ""
admin_username: str = "admin"
admin_password: str = "admin"
secret_key: str = "dev-secret-change-me"
database_path: str = "data/app.db"
remnawave_base_url: str = "http://127.0.0.1:3000"
remnawave_token: str = ""
session_max_age: int = 60 * 60 * 24 * 7
@lru_cache
def get_settings() -> Settings:
return Settings()
+81
View File
@@ -0,0 +1,81 @@
import aiosqlite
from pathlib import Path
from config import get_settings
async def get_db() -> aiosqlite.Connection:
settings = get_settings()
path = Path(settings.database_path)
path.parent.mkdir(parents=True, exist_ok=True)
db = await aiosqlite.connect(path)
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA foreign_keys = ON")
return db
async def _ensure_column(db: aiosqlite.Connection, table: str, column: str, ddl: str) -> None:
cursor = await db.execute(f"PRAGMA table_info({table})")
cols = {row[1] for row in await cursor.fetchall()}
if column not in cols:
await db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
async def init_db() -> None:
db = await get_db()
try:
await db.executescript(
"""
CREATE TABLE IF NOT EXISTS guest_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
link_type TEXT NOT NULL CHECK(link_type IN ('extend', 'traffic')),
title TEXT NOT NULL DEFAULT '',
value INTEGER NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
note TEXT NOT NULL DEFAULT '',
duration_days INTEGER NOT NULL DEFAULT 0,
plan_traffic_gb INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS guest_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_id INTEGER NOT NULL,
username TEXT NOT NULL,
user_uuid TEXT,
detail TEXT NOT NULL DEFAULT '',
claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (link_id) REFERENCES guest_links(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS traffic_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id INTEGER,
link_id INTEGER NOT NULL,
user_uuid TEXT NOT NULL,
username TEXT NOT NULL DEFAULT '',
bytes_added INTEGER NOT NULL,
expires_at TEXT NOT NULL,
reverted_at TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'reverted', 'error')),
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (link_id) REFERENCES guest_links(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_guest_links_token ON guest_links(token);
CREATE INDEX IF NOT EXISTS idx_guest_claims_link ON guest_claims(link_id);
CREATE INDEX IF NOT EXISTS idx_traffic_grants_active
ON traffic_grants(status, expires_at);
"""
)
await _ensure_column(db, "guest_links", "duration_days", "INTEGER NOT NULL DEFAULT 0")
await _ensure_column(db, "guest_links", "plan_traffic_gb", "INTEGER NOT NULL DEFAULT 0")
await db.commit()
finally:
await db.close()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
import asyncio
import logging
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from config import get_settings
from database import init_db
from routers import admin, guest, pages
from traffic_worker import traffic_grant_worker
logging.basicConfig(level=logging.INFO)
@asynccontextmanager
async def lifespan(_: FastAPI):
Path(get_settings().database_path).parent.mkdir(parents=True, exist_ok=True)
await init_db()
stop_event = asyncio.Event()
worker = asyncio.create_task(traffic_grant_worker(stop_event))
yield
stop_event.set()
await worker
app = FastAPI(title="Remna Site", lifespan=lifespan)
settings = get_settings()
# Dokploy/Traefik: корректный https и Host
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
max_age=settings.session_max_age,
same_site="lax",
https_only=False,
)
app.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(pages.router)
app.include_router(admin.router)
app.include_router(guest.router)
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import quote
import httpx
from config import get_settings
class RemnawaveError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class RemnawaveClient:
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.remnawave_base_url.rstrip("/")
self.token = settings.remnawave_token
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
if not self.token:
raise RemnawaveError("REMNAWAVE_TOKEN не задан в .env")
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method, url, headers=self._headers(), **kwargs
)
if response.status_code >= 400:
detail = response.text[:500]
raise RemnawaveError(
f"Remnawave API error {response.status_code}: {detail}",
response.status_code,
)
if not response.content:
return None
data = response.json()
if isinstance(data, dict) and "response" in data:
return data["response"]
return data
async def find_user(self, identifier: str) -> dict[str, Any]:
identifier = identifier.strip()
if not identifier:
raise RemnawaveError("Укажите username или UUID")
encoded = quote(identifier, safe="")
for resolve_body in (
{"username": identifier},
{"shortUuid": identifier},
{"uuid": identifier},
):
try:
resolved = await self._request(
"POST", "/api/users/resolve", json=resolve_body
)
uuid = None
if isinstance(resolved, dict):
uuid = resolved.get("uuid")
if uuid:
user = await self._request("GET", f"/api/users/{uuid}")
if user:
return user
except RemnawaveError as exc:
if exc.status_code not in (404, 400, 422):
raise
attempts = [
f"/api/users/by-username/{encoded}",
f"/api/users/by-short-uuid/{encoded}",
f"/api/users/{encoded}",
]
last_error: RemnawaveError | None = None
for path in attempts:
try:
user = await self._request("GET", path)
if user:
return user
except RemnawaveError as exc:
last_error = exc
if exc.status_code not in (404, 400, 422):
raise
raise RemnawaveError(
"Пользователь не найден. Проверьте username или short UUID."
) from last_error
async def update_user(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("PATCH", "/api/users", json=payload)
@staticmethod
def _parse_expire_at(value: str | None) -> datetime:
if not value:
return datetime.now(timezone.utc)
raw = str(value).replace("Z", "+00:00")
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
async def extend_subscription(
self,
identifier: str,
days: int,
plan_traffic_gb: int,
) -> dict[str, Any]:
"""Продление + фиксация лимита тарифа + сброс использованного трафика."""
user = await self.find_user(identifier)
now = datetime.now(timezone.utc)
current = self._parse_expire_at(user.get("expireAt") or user.get("expire_at"))
base = current if current > now else now
new_expire = base + timedelta(days=days)
old_limit = int(
user.get("trafficLimitBytes")
or user.get("traffic_limit_bytes")
or 0
)
new_limit = int(plan_traffic_gb) * 1024**3
updated = await self.update_user(
{
"uuid": user["uuid"],
"expireAt": new_expire.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"trafficLimitBytes": new_limit,
}
)
# Сброс счётчика использованного трафика — новый период тарифа
try:
await self._request(
"POST", f"/api/users/{user['uuid']}/actions/reset-traffic"
)
except RemnawaveError:
# лимит уже выставлен; сброс — желателен, но не откатываем продление
pass
return {
"user": updated or user,
"old_expire": current.isoformat(),
"new_expire": new_expire.isoformat(),
"days": days,
"old_limit_gb": round(old_limit / 1024**3, 2),
"new_limit_gb": plan_traffic_gb,
"plan_traffic_gb": plan_traffic_gb,
"username": user.get("username"),
"uuid": user.get("uuid"),
}
async def add_traffic(self, identifier: str, gigabytes: int) -> dict[str, Any]:
user = await self.find_user(identifier)
current_limit = int(
user.get("trafficLimitBytes")
or user.get("traffic_limit_bytes")
or 0
)
add_bytes = gigabytes * 1024**3
new_limit = current_limit + add_bytes
updated = await self.update_user(
{
"uuid": user["uuid"],
"trafficLimitBytes": new_limit,
}
)
return {
"user": updated or user,
"old_limit_bytes": current_limit,
"new_limit_bytes": new_limit,
"old_limit_gb": round(current_limit / 1024**3, 2),
"new_limit_gb": round(new_limit / 1024**3, 2),
"added_gb": gigabytes,
"bytes_added": add_bytes,
"username": user.get("username"),
"uuid": user.get("uuid"),
}
async def remove_traffic_bytes(self, user_uuid: str, bytes_to_remove: int) -> dict[str, Any]:
user = await self._request("GET", f"/api/users/{user_uuid}")
if not user:
raise RemnawaveError("Пользователь не найден при откате трафика", 404)
current_limit = int(
user.get("trafficLimitBytes")
or user.get("traffic_limit_bytes")
or 0
)
new_limit = max(0, current_limit - int(bytes_to_remove))
updated = await self.update_user(
{
"uuid": user["uuid"],
"trafficLimitBytes": new_limit,
}
)
return {
"user": updated or user,
"old_limit_bytes": current_limit,
"new_limit_bytes": new_limit,
"username": user.get("username"),
"uuid": user.get("uuid"),
}
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
jinja2==3.1.6
python-multipart==0.0.20
httpx==0.28.1
itsdangerous==2.2.0
pydantic-settings==2.9.1
aiosqlite==0.21.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
python-dotenv==1.1.0
+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"}
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
LinkType = Literal["extend", "traffic"]
class GuestLinkCreate(BaseModel):
link_type: LinkType
title: str = Field(default="", max_length=120)
value: int = Field(ge=1, le=100000)
max_uses: int = Field(default=1, ge=1, le=100000)
expires_at: str | None = None
note: str = Field(default="", max_length=500)
class GuestClaimRequest(BaseModel):
identifier: str = Field(min_length=1, max_length=128)
class LoginRequest(BaseModel):
username: str
password: str
+594
View File
@@ -0,0 +1,594 @@
:root {
--ink: #071411;
--deep: #0c2420;
--moss: #1a3d36;
--fog: #d7e4df;
--mist: #9bb5ad;
--signal: #d4ff3f;
--signal-dim: #a8c932;
--ember: #ff7a45;
--paper: #eef6f2;
--line: rgba(215, 228, 223, 0.14);
--font-display: "Bricolage Grotesque", sans-serif;
--font-body: "Sora", sans-serif;
--radius: 2px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-body);
color: var(--fog);
background: var(--ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
.site-nav {
position: absolute;
inset: 0 0 auto;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.4rem clamp(1.2rem, 4vw, 3.5rem);
}
.nav-brand {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.95rem;
text-transform: uppercase;
}
.nav-links {
display: flex;
gap: 1.5rem;
align-items: center;
font-size: 0.85rem;
color: var(--mist);
}
.nav-links a:hover {
color: var(--signal);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
padding: 0.85rem 1.4rem;
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.25s ease, background 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-signal {
background: var(--signal);
color: var(--ink);
}
.btn-signal:hover {
background: #e7ff78;
}
.btn-ghost {
background: transparent;
border-color: var(--line);
color: var(--fog);
}
.btn-ghost:hover {
border-color: var(--signal);
color: var(--signal);
}
.btn-danger {
background: transparent;
border-color: rgba(255, 122, 69, 0.45);
color: var(--ember);
}
/* Landing hero */
.hero {
position: relative;
min-height: 100vh;
min-height: 100dvh;
display: grid;
align-items: end;
overflow: hidden;
isolation: isolate;
}
.hero-bg {
position: absolute;
inset: 0;
z-index: -2;
background:
radial-gradient(ellipse 80% 60% at 70% 20%, rgba(212, 255, 63, 0.12), transparent 55%),
radial-gradient(ellipse 70% 50% at 15% 80%, rgba(26, 61, 54, 0.9), transparent 60%),
linear-gradient(160deg, #04110e 0%, #0c2420 42%, #071411 100%);
}
.hero-bg::after {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(215, 228, 223, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(215, 228, 223, 0.04) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: radial-gradient(ellipse at 60% 40%, black 20%, transparent 75%);
animation: grid-drift 28s linear infinite;
}
.hero-orb {
position: absolute;
z-index: -1;
width: min(70vw, 720px);
aspect-ratio: 1;
right: -8%;
top: 8%;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, rgba(212, 255, 63, 0.35), transparent 28%),
radial-gradient(circle at 60% 55%, rgba(26, 61, 54, 0.8), transparent 55%),
conic-gradient(from 210deg, transparent, rgba(212, 255, 63, 0.18), transparent 40%);
filter: blur(2px);
animation: orb-breathe 9s ease-in-out infinite;
}
.hero-inner {
width: min(1180px, 100%);
margin: 0 auto;
padding: clamp(7rem, 14vh, 10rem) clamp(1.2rem, 4vw, 3.5rem) clamp(3rem, 8vh, 5rem);
}
.hero-brand {
font-family: var(--font-display);
font-size: clamp(4.2rem, 14vw, 9.5rem);
font-weight: 800;
line-height: 0.9;
letter-spacing: -0.04em;
margin: 0 0 1.4rem;
color: var(--paper);
animation: rise 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-title {
max-width: 16ch;
font-family: var(--font-display);
font-size: clamp(1.55rem, 3.4vw, 2.45rem);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.02em;
margin: 0 0 1rem;
color: var(--fog);
animation: rise 0.9s 0.12s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-lead {
max-width: 38ch;
margin: 0 0 2rem;
color: var(--mist);
font-size: 1.05rem;
animation: rise 0.9s 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-cta {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
animation: rise 0.9s 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.section {
padding: clamp(4rem, 10vh, 7rem) clamp(1.2rem, 4vw, 3.5rem);
}
.section-inner {
width: min(1100px, 100%);
margin: 0 auto;
}
.section h2 {
font-family: var(--font-display);
font-size: clamp(1.8rem, 4vw, 2.8rem);
letter-spacing: -0.03em;
margin: 0 0 0.75rem;
color: var(--paper);
}
.section p.lead {
max-width: 48ch;
color: var(--mist);
margin: 0 0 2.5rem;
}
.features {
display: grid;
gap: 2.5rem;
grid-template-columns: repeat(3, 1fr);
border-top: 1px solid var(--line);
padding-top: 2rem;
}
.feature h3 {
font-family: var(--font-display);
font-size: 1.15rem;
margin: 0 0 0.55rem;
color: var(--signal);
}
.feature p {
margin: 0;
color: var(--mist);
font-size: 0.95rem;
}
.how {
background:
linear-gradient(180deg, rgba(12, 36, 32, 0.5), transparent),
var(--ink);
}
.steps {
display: grid;
gap: 1.5rem;
}
.step {
display: grid;
grid-template-columns: auto 1fr;
gap: 1.2rem;
align-items: start;
padding: 1.2rem 0;
border-bottom: 1px solid var(--line);
}
.step-num {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--signal);
min-width: 2ch;
}
.step h3 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
}
.step p {
margin: 0;
color: var(--mist);
}
.cta-band {
text-align: center;
padding-bottom: 5rem;
}
.cta-band h2 {
margin-bottom: 1.2rem;
}
.site-footer {
border-top: 1px solid var(--line);
padding: 1.5rem clamp(1.2rem, 4vw, 3.5rem);
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
color: var(--mist);
font-size: 0.85rem;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes orb-breathe {
0%,
100% {
transform: scale(1) translate(0, 0);
}
50% {
transform: scale(1.06) translate(-2%, 3%);
}
}
@keyframes grid-drift {
from {
transform: translate(0, 0);
}
to {
transform: translate(-72px, -72px);
}
}
@media (max-width: 860px) {
.features {
grid-template-columns: 1fr;
}
.nav-links a:not(.btn) {
display: none;
}
}
/* Shared panel shells */
.panel-body {
min-height: 100vh;
background:
radial-gradient(ellipse 60% 40% at 10% 0%, rgba(212, 255, 63, 0.08), transparent 50%),
linear-gradient(180deg, #0a1c18, var(--ink));
}
.panel-wrap {
width: min(1080px, 100%);
margin: 0 auto;
padding: 2rem clamp(1rem, 3vw, 2rem) 4rem;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.panel-header h1 {
font-family: var(--font-display);
font-size: clamp(1.6rem, 3vw, 2.2rem);
margin: 0;
letter-spacing: -0.03em;
}
.muted {
color: var(--mist);
}
.flash {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border-left: 3px solid var(--signal);
background: rgba(212, 255, 63, 0.08);
color: var(--fog);
}
.alert-error {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border-left: 3px solid var(--ember);
background: rgba(255, 122, 69, 0.1);
color: #ffc4ad;
}
.alert-ok {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border-left: 3px solid var(--signal);
background: rgba(212, 255, 63, 0.1);
color: var(--fog);
}
.form-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--line);
}
.form-grid .full {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--mist);
}
input,
select,
textarea {
width: 100%;
background: rgba(7, 20, 17, 0.7);
border: 1px solid var(--line);
color: var(--fog);
padding: 0.75rem 0.85rem;
font: inherit;
border-radius: var(--radius);
}
input:focus,
select:focus,
textarea:focus {
outline: 1px solid var(--signal);
border-color: var(--signal);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
padding: 0.85rem 0.6rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
color: var(--mist);
font-weight: 500;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge {
display: inline-block;
padding: 0.2rem 0.45rem;
font-size: 0.75rem;
border: 1px solid var(--line);
}
.badge-extend {
color: var(--signal);
border-color: rgba(212, 255, 63, 0.35);
}
.badge-traffic {
color: #7ec8ff;
border-color: rgba(126, 200, 255, 0.35);
}
.badge-off {
color: var(--ember);
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
word-break: break-all;
}
.btn-copy {
padding: 0.45rem 0.75rem;
font-size: 0.8rem;
white-space: nowrap;
}
.btn-copy.is-copied {
border-color: var(--signal);
color: var(--signal);
}
.link-copy-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
}
.link-preview {
max-width: min(520px, 100%);
}
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse 50% 40% at 50% 0%, rgba(212, 255, 63, 0.1), transparent 55%),
var(--ink);
}
.login-box {
width: min(420px, 100%);
}
.login-box h1 {
font-family: var(--font-display);
font-size: 2rem;
margin: 0 0 0.35rem;
}
.login-box form {
display: grid;
gap: 1rem;
margin-top: 1.5rem;
}
.guest-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse 55% 45% at 80% 10%, rgba(212, 255, 63, 0.1), transparent 50%),
linear-gradient(165deg, #0c2420, #071411 60%);
}
.guest-box {
width: min(480px, 100%);
}
.guest-box h1 {
font-family: var(--font-display);
font-size: clamp(1.6rem, 4vw, 2.1rem);
margin: 0 0 0.5rem;
letter-spacing: -0.03em;
}
.guest-meta {
color: var(--mist);
margin-bottom: 1.5rem;
}
.guest-box form {
display: grid;
gap: 1rem;
}
@media (max-width: 700px) {
.form-grid {
grid-template-columns: 1fr;
}
}
+32
View File
@@ -0,0 +1,32 @@
async function copyText(text, button) {
try {
await navigator.clipboard.writeText(text);
} catch {
const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.left = "-9999px";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}
if (!button) return;
const prev = button.textContent;
button.textContent = "Скопировано";
button.classList.add("is-copied");
setTimeout(() => {
button.textContent = prev;
button.classList.remove("is-copied");
}, 1500);
}
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-copy]");
if (!button) return;
event.preventDefault();
const text = button.getAttribute("data-copy") || "";
if (text) copyText(text, button);
});
+16
View File
@@ -0,0 +1,16 @@
document.addEventListener("DOMContentLoaded", () => {
const orb = document.querySelector(".hero-orb");
if (!orb || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
window.addEventListener(
"pointermove",
(event) => {
const x = (event.clientX / window.innerWidth - 0.5) * 18;
const y = (event.clientY / window.innerHeight - 0.5) * 12;
orb.style.translate = `${x}px ${y}px`;
},
{ passive: true }
);
});
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Админка — {{ brand }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Sora:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body class="panel-body">
<div class="panel-wrap">
<header class="panel-header">
<div>
<h1>Гостевые ссылки</h1>
<p class="muted">Продление фиксирует трафик тарифа; докупка даёт временные ГБ</p>
</div>
<div class="row-actions">
<a class="btn btn-ghost" href="/">На сайт</a>
<form method="post" action="/admin/logout">
<button class="btn btn-danger" type="submit">Выйти</button>
</form>
</div>
</header>
{% if flash == "created" %}
<div class="flash">Ссылка создана</div>
{% elif flash == "deleted" %}
<div class="flash">Ссылка удалена</div>
{% elif flash == "toggled" %}
<div class="flash">Статус ссылки обновлён</div>
{% endif %}
<h2 style="font-family:var(--font-display);font-size:1.25rem;margin:0 0 1rem;">Создать ссылку</h2>
<form class="form-grid" method="post" action="/admin/links">
<label>
Направление
<select name="link_type" required>
<option value="extend">Продление VPN (дни)</option>
<option value="traffic">Докупка трафика (ГБ)</option>
</select>
</label>
<label>
Значение
<input type="number" name="value" min="1" value="30" required />
<span class="muted" style="font-size:0.75rem;">Продление = дни · Трафик = ГБ бонуса</span>
</label>
<label>
Трафик тарифа (ГБ)
<input type="number" name="plan_traffic_gb" min="0" value="100" />
<span class="muted" style="font-size:0.75rem;">Только продление: лимит станет ровно столько (и сбросится счётчик). Пример: тариф 49₽ → 100</span>
</label>
<label>
Срок бонус-трафика (дней)
<input type="number" name="duration_days" min="0" value="7" />
<span class="muted" style="font-size:0.75rem;">Только докупка: через N дней бонус-ГБ снимутся</span>
</label>
<label>
Заголовок
<input type="text" name="title" placeholder="Напр. +50 ГБ на 7 дней" maxlength="120" />
</label>
<label>
Макс. активаций
<input type="number" name="max_uses" min="1" value="1" required />
</label>
<label>
Срок жизни ссылки (опционально)
<input type="datetime-local" name="expires_at" />
</label>
<label class="full">
Заметка
<textarea name="note" rows="2" placeholder="Для себя"></textarea>
</label>
<div class="full">
<button class="btn btn-signal" type="submit">Создать гостевую ссылку</button>
</div>
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Тип</th>
<th>Заголовок</th>
<th>Значение</th>
<th>Использовано</th>
<th>Ссылка</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{% for link in links %}
<tr>
<td>
{% if link.link_type == "extend" %}
<span class="badge badge-extend">Продление</span>
{% else %}
<span class="badge badge-traffic">Трафик</span>
{% endif %}
</td>
<td>{{ link.title }}</td>
<td>
{% if link.link_type == "extend" %}
+{{ link.value }} дн. / {{ link.plan_traffic_gb or "?" }} ГБ
{% else %}
+{{ link.value }} ГБ / {{ link.duration_days or "?" }} дн.
{% endif %}
</td>
<td>{{ link.used_count }} / {{ link.max_uses }}</td>
<td>
<button
type="button"
class="btn btn-ghost btn-copy"
data-copy="{{ site_base }}/g/{{ link.token }}"
title="{{ site_base }}/g/{{ link.token }}"
>Копировать</button>
</td>
<td>
{% if link.is_active %}
активна
{% else %}
<span class="badge badge-off">выкл</span>
{% endif %}
</td>
<td>
<div class="row-actions">
<a class="btn btn-ghost" href="/admin/links/{{ link.id }}">История</a>
<form method="post" action="/admin/links/{{ link.id }}/toggle">
<button class="btn btn-ghost" type="submit">{% if link.is_active %}Выкл{% else %}Вкл{% endif %}</button>
</form>
<form method="post" action="/admin/links/{{ link.id }}/delete" onsubmit="return confirm('Удалить ссылку?')">
<button class="btn btn-danger" type="submit">Удалить</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="muted">Пока нет ссылок — создайте первую выше.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script src="/static/js/admin.js"></script>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ link.title }} — {{ brand }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Sora:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body class="panel-body">
<div class="panel-wrap">
<header class="panel-header">
<div>
<h1>{{ link.title }}</h1>
<div class="link-copy-row">
<button
type="button"
class="btn btn-signal btn-copy"
data-copy="{{ site_base }}/g/{{ link.token }}"
>Копировать ссылку</button>
<span class="muted mono link-preview">{{ site_base }}/g/{{ link.token }}</span>
</div>
</div>
<a class="btn btn-ghost" href="/admin">← Назад</a>
</header>
<p class="muted">
{% if link.link_type == "extend" %}
Продление: +{{ link.value }} дн. · тариф {{ link.plan_traffic_gb }} ГБ
{% else %}
Трафик: +{{ link.value }} ГБ на {{ link.duration_days }} дн. (временный)
{% endif %}
· {{ link.used_count }}/{{ link.max_uses }}
{% if link.note %} · {{ link.note }}{% endif %}
</p>
<div class="table-wrap" style="margin-top:1.5rem;">
<h2 style="font-family:var(--font-display);font-size:1.1rem;">Активации</h2>
<table>
<thead>
<tr>
<th>Когда</th>
<th>Пользователь</th>
<th>UUID</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td>{{ claim.claimed_at }}</td>
<td>{{ claim.username }}</td>
<td class="mono">{{ claim.user_uuid or "—" }}</td>
<td>{{ claim.detail }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="muted">Активаций ещё не было</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if link.link_type == "traffic" %}
<div class="table-wrap" style="margin-top:2rem;">
<h2 style="font-family:var(--font-display);font-size:1.1rem;">Временный трафик</h2>
<table>
<thead>
<tr>
<th>Пользователь</th>
<th>ГБ</th>
<th>До</th>
<th>Статус</th>
<th>Снят</th>
</tr>
</thead>
<tbody>
{% for g in grants %}
<tr>
<td>{{ g.username }}</td>
<td>+{{ (g.bytes_added / 1073741824)|round(2) }}</td>
<td>{{ g.expires_at[:19] }}</td>
<td>{{ g.status }}{% if g.last_error %} <span class="badge badge-off">err</span>{% endif %}</td>
<td>{{ g.reverted_at[:19] if g.reverted_at else "—" }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="muted">Выдач ещё не было</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<script src="/static/js/admin.js"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Вход — {{ brand }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Sora:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body class="login-shell">
<div class="login-box">
<h1>{{ brand }} Admin</h1>
<p class="muted">Создание гостевых ссылок на продление и трафик</p>
{% if error %}
<div class="alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/login">
<label>
Логин
<input type="text" name="username" required autocomplete="username" />
</label>
<label>
Пароль
<input type="password" name="password" required autocomplete="current-password" />
</label>
<button class="btn btn-signal" type="submit">Войти</button>
</form>
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ link.title }} — {{ brand }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Sora:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body class="guest-shell">
<div class="guest-box">
<p class="muted" style="letter-spacing:0.08em;text-transform:uppercase;font-size:0.75rem;">{{ brand }}</p>
<h1>{{ link.title }}</h1>
<p class="guest-meta">
{% if link.link_type == "extend" %}
Направление: <strong>продление тарифа</strong><br />
Срок: <strong>+{{ link.value }} дн.</strong> ·
Трафик тарифа: <strong>{{ link.plan_traffic_gb }} ГБ</strong>
<br /><span class="muted">Лимит будет выставлен фиксированно на {{ link.plan_traffic_gb }} ГБ, использованный трафик сбросится.</span>
{% else %}
Направление: <strong>временный трафик</strong>
<strong>+{{ link.value }} ГБ</strong> на <strong>{{ link.duration_days }} дн.</strong>
<br /><span class="muted">После срока лимит автоматически уменьшится на эти ГБ.</span>
{% endif %}
</p>
{% if error %}
<div class="alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert-ok">Готово: {{ success }}</div>
{% endif %}
{% if not blocked %}
<form method="post">
<label>
Username или short UUID из Remnawave
<input type="text" name="identifier" required placeholder="например: user123" autocomplete="off" />
</label>
<button class="btn btn-signal" type="submit">
{% if link.link_type == "extend" %}Получить продление{% else %}Получить трафик{% endif %}
</button>
</form>
{% endif %}
</div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ссылка недоступна — {{ brand }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Sora:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body class="guest-shell">
<div class="guest-box">
<h1>{{ brand }}</h1>
<div class="alert-error">{{ message }}</div>
<a class="btn btn-ghost" href="/">На главную</a>
</div>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ brand }} — защищённый доступ</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Sora:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<header class="site-nav">
<div class="nav-brand">{{ brand }}</div>
<nav class="nav-links">
<a href="#features">Возможности</a>
<a href="#how">Как начать</a>
<a class="btn btn-ghost" href="/admin">Админ</a>
</nav>
</header>
<section class="hero">
<div class="hero-bg" aria-hidden="true"></div>
<div class="hero-orb" aria-hidden="true"></div>
<div class="hero-inner">
<p class="hero-brand">{{ brand }}</p>
<h1 class="hero-title">Стабильный VPN-канал без лишнего шума</h1>
<p class="hero-lead">Продление подписки и докупка трафика по гостевой ссылке — быстро и без лишних шагов.</p>
<div class="hero-cta">
<a class="btn btn-signal" href="#how">Подключиться</a>
<a class="btn btn-ghost" href="#features">Смотреть возможности</a>
</div>
</div>
</section>
<section class="section" id="features">
<div class="section-inner">
<h2>Сигнал чистый. Маршрут ваш.</h2>
<p class="lead">Один сервис для подключения, продления и докупки трафика — без кассы в каждом чате.</p>
<div class="features">
<article class="feature">
<h3>Скорость без просадок</h3>
<p>Современные протоколы и маршрутизация через Remnawave — меньше обрывов, больше стабильности.</p>
</article>
<article class="feature">
<h3>Продление по ссылке</h3>
<p>Гостевая ссылка на продление: клиент вводит username и сразу получает дополнительные дни.</p>
</article>
<article class="feature">
<h3>Докупка трафика</h3>
<p>Отдельное направление для ГБ: лимит растёт мгновенно через панель, без ручных правок.</p>
</article>
</div>
</div>
</section>
<section class="section how" id="how">
<div class="section-inner">
<h2>Три шага до доступа</h2>
<p class="lead">Короткий путь от заявки до рабочего ключа.</p>
<div class="steps">
<div class="step">
<div class="step-num">01</div>
<div>
<h3>Получите конфиг</h3>
<p>После оплаты вы получаете конфиг подписки — импортируете в клиент за минуту.</p>
</div>
</div>
<div class="step">
<div class="step-num">02</div>
<div>
<h3>Подключитесь</h3>
<p>Включаете профиль и работаете: сайты, стримы, сервисы — через защищённый туннель.</p>
</div>
</div>
<div class="step">
<div class="step-num">03</div>
<div>
<h3>Продлите или докупите</h3>
<p>По гостевой ссылке продлеваете срок или добавляете трафик — это разные направления, без путаницы.</p>
</div>
</div>
</div>
</div>
</section>
<section class="section cta-band">
<div class="section-inner">
<h2>Готовы к чистому каналу?</h2>
<a class="btn btn-signal" href="/admin">Открыть панель управления</a>
</div>
</section>
<footer class="site-footer">
<span>{{ brand }}</span>
<span>Гостевые ссылки · продление и трафик</span>
</footer>
<script src="/static/js/main.js"></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from database import get_db
from remnawave import RemnawaveClient, RemnawaveError
logger = logging.getLogger("traffic_grants")
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
async def revert_expired_grants() -> int:
"""Снимает временный трафик после expires_at. Возвращает число успешных откатов."""
now = _now_iso()
db = await get_db()
try:
cursor = await db.execute(
"""
SELECT * FROM traffic_grants
WHERE status = 'active' AND expires_at <= ?
ORDER BY id ASC
""",
(now,),
)
grants = [dict(row) for row in await cursor.fetchall()]
finally:
await db.close()
if not grants:
return 0
client = RemnawaveClient()
done = 0
for grant in grants:
try:
result = await client.remove_traffic_bytes(
grant["user_uuid"], int(grant["bytes_added"])
)
db = await get_db()
try:
await db.execute(
"""
UPDATE traffic_grants
SET status = 'reverted', reverted_at = ?, last_error = ''
WHERE id = ?
""",
(_now_iso(), grant["id"]),
)
await db.commit()
finally:
await db.close()
done += 1
logger.info(
"Reverted traffic grant #%s user=%s -%s bytes (limit %s%s)",
grant["id"],
grant.get("username") or grant["user_uuid"],
grant["bytes_added"],
result.get("old_limit_bytes"),
result.get("new_limit_bytes"),
)
except RemnawaveError as exc:
db = await get_db()
try:
await db.execute(
"""
UPDATE traffic_grants
SET last_error = ?
WHERE id = ?
""",
(str(exc)[:500], grant["id"]),
)
await db.commit()
finally:
await db.close()
logger.warning("Failed to revert grant #%s: %s", grant["id"], exc)
return done
async def traffic_grant_worker(stop_event: asyncio.Event) -> None:
while not stop_event.is_set():
try:
await revert_expired_grants()
except Exception:
logger.exception("traffic grant worker error")
try:
await asyncio.wait_for(stop_event.wait(), timeout=60)
except asyncio.TimeoutError:
pass
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import Request
from config import get_settings
def public_base_url(request: Request) -> str:
"""URL сайта: домен из Dokploy (Host), без прописывания в .env."""
settings = get_settings()
if settings.site_public_url.strip():
return settings.site_public_url.strip().rstrip("/")
proto = (
request.headers.get("x-forwarded-proto")
or request.url.scheme
or "https"
).split(",")[0].strip()
host = (
request.headers.get("x-forwarded-host")
or request.headers.get("host")
or request.url.netloc
)
host = host.split(",")[0].strip()
return f"{proto}://{host}".rstrip("/")
+5
View File
@@ -0,0 +1,5 @@
https://rm.de4ima.uk:9000 {
tls /certs/certificates/rm.de4ima.uk.crt /certs/certificates/rm.de4ima.uk.key
encode gzip
reverse_proxy web:8000
}
+22
View File
@@ -0,0 +1,22 @@
# Default build from source (удобно на aaPanel без Docker Hub)
services:
web:
build:
context: ./app
dockerfile: Dockerfile
image: remna-site:local
container_name: remna-web
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000"
env_file: .env
environment:
DATABASE_PATH: /data/app.db
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
+25
View File
@@ -0,0 +1,25 @@
services:
web:
image: ${DOCKERHUB_USER:?}/remna-site:${IMAGE_TAG:-latest}
restart: unless-stopped
ports:
- "8000"
environment:
DATABASE_PATH: /data/app.db
BRAND_NAME: ${BRAND_NAME:-REMNA}
ADMIN_USERNAME: ${ADMIN_USERNAME:?}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?}
SECRET_KEY: ${SECRET_KEY:?}
REMNAWAVE_BASE_URL: ${REMNAWAVE_BASE_URL:?}
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN:?}
volumes:
- remna-web-data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
remna-web-data:
+72
View File
@@ -0,0 +1,72 @@
# Опционально: старый стек без Coolify (Caddy :9000 + lego)
# docker compose -f docker-compose.standalone.yml up -d --build
services:
web:
build: ./app
container_name: remna-web
restart: always
env_file: .env
environment:
DATABASE_PATH: /data/app.db
volumes:
- web-data:/data
expose:
- "8000"
networks:
- site-network
lego:
image: goacme/lego:v4.22.2
container_name: remna-lego
env_file: .env
environment:
CF_DNS_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
volumes:
- lego-certs:/letsencrypt
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
CERT="/letsencrypt/certificates/rm.de4ima.uk.crt"
if [ ! -f "$$CERT" ]; then
/lego --path /letsencrypt --email "$$ACME_EMAIL" --domains rm.de4ima.uk --dns cloudflare --accept-tos run
else
/lego --path /letsencrypt --email "$$ACME_EMAIL" --domains rm.de4ima.uk --dns cloudflare --accept-tos renew --days 30 || true
fi
sleep infinity
networks:
- site-network
restart: unless-stopped
caddy:
image: caddy:2.9
container_name: remna-caddy
restart: always
ports:
- "0.0.0.0:9000:9000"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- lego-certs:/certs:ro
- caddy-ssl-data:/data
- caddy-config:/config
depends_on:
- web
- lego
entrypoint: ["/bin/sh", "-c"]
command:
- |
CERT="/certs/certificates/rm.de4ima.uk.crt"
while [ ! -f "$$CERT" ]; do sleep 2; done
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
networks:
- site-network
networks:
site-network:
name: remna-site-network
volumes:
web-data:
lego-certs:
caddy-ssl-data:
caddy-config:
+29
View File
@@ -0,0 +1,29 @@
# Dokploy — Docker Compose
# Домен НЕ в env — укажите в Dokploy → Domains (web, port 8000)
services:
web:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8000"
environment:
DATABASE_PATH: /data/app.db
BRAND_NAME: ${BRAND_NAME:-REMNA}
ADMIN_USERNAME: ${ADMIN_USERNAME:?}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?}
SECRET_KEY: ${SECRET_KEY:?}
REMNAWAVE_BASE_URL: ${REMNAWAVE_BASE_URL:?}
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN:?}
volumes:
- remna-web-data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
remna-web-data:
+14
View File
@@ -0,0 +1,14 @@
# Fallback, если Dokploy выбрал Nixpacks вместо Docker/Compose
providers = ["python"]
[variables]
NIXPACKS_PYTHON_VERSION = "3.12"
[phases.setup]
nixPkgs = ["python312", "gcc"]
[phases.install]
cmds = ["python -m pip install --upgrade pip", "pip install -r app/requirements.txt"]
[start]
cmd = "cd app && uvicorn main:app --host 0.0.0.0 --port 8000"