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