first commit
This commit is contained in:
@@ -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
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
data/
|
||||
*.db
|
||||
.DS_Store
|
||||
|
||||
*.zip
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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("/")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user