commit 3ca7f9f7cc6645f02b9e9bf42da8be031b85ad99 Author: orohi Date: Sat Jul 25 00:37:09 2026 +0300 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94817fe --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5ba1dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ +data/ +*.db +.DS_Store + +*.zip diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..d5fdb66 --- /dev/null +++ b/DEPLOY.md @@ -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 diff --git a/DOCKERHUB.md b/DOCKERHUB.md new file mode 100644 index 0000000..50b1b8a --- /dev/null +++ b/DOCKERHUB.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1a4f58 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2be02d0 --- /dev/null +++ b/README.md @@ -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) diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..c6331b8 --- /dev/null +++ b/app/Dockerfile @@ -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"] diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..dcf590a --- /dev/null +++ b/app/auth.py @@ -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") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..502d532 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..352ffcc --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8352bf5 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/remnawave.py b/app/remnawave.py new file mode 100644 index 0000000..65046fd --- /dev/null +++ b/app/remnawave.py @@ -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"), + } diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..4ea805f --- /dev/null +++ b/app/requirements.txt @@ -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 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..0e632e1 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..1e57324 --- /dev/null +++ b/app/routers/admin.py @@ -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), + }, + ) diff --git a/app/routers/guest.py b/app/routers/guest.py new file mode 100644 index 0000000..ec0e93a --- /dev/null +++ b/app/routers/guest.py @@ -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, + }, + ) diff --git a/app/routers/pages.py b/app/routers/pages.py new file mode 100644 index 0000000..ca631e7 --- /dev/null +++ b/app/routers/pages.py @@ -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"} diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..487de6d --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..f4b755c --- /dev/null +++ b/app/static/css/style.css @@ -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; + } +} diff --git a/app/static/js/admin.js b/app/static/js/admin.js new file mode 100644 index 0000000..8eaf1c5 --- /dev/null +++ b/app/static/js/admin.js @@ -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); +}); diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..2f490fc --- /dev/null +++ b/app/static/js/main.js @@ -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 } + ); +}); diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..9b85c07 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,150 @@ + + + + + + Админка — {{ brand }} + + + + + + +
+
+
+

Гостевые ссылки

+

Продление фиксирует трафик тарифа; докупка даёт временные ГБ

+
+
+ На сайт +
+ +
+
+
+ + {% if flash == "created" %} +
Ссылка создана
+ {% elif flash == "deleted" %} +
Ссылка удалена
+ {% elif flash == "toggled" %} +
Статус ссылки обновлён
+ {% endif %} + +

Создать ссылку

+
+ + + + + + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + {% for link in links %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ТипЗаголовокЗначениеИспользованоСсылкаСтатус
+ {% if link.link_type == "extend" %} + Продление + {% else %} + Трафик + {% endif %} + {{ link.title }} + {% if link.link_type == "extend" %} + +{{ link.value }} дн. / {{ link.plan_traffic_gb or "?" }} ГБ + {% else %} + +{{ link.value }} ГБ / {{ link.duration_days or "?" }} дн. + {% endif %} + {{ link.used_count }} / {{ link.max_uses }} + + + {% if link.is_active %} + активна + {% else %} + выкл + {% endif %} + +
+ История +
+ +
+
+ +
+
+
Пока нет ссылок — создайте первую выше.
+
+
+ + + diff --git a/app/templates/admin/link_detail.html b/app/templates/admin/link_detail.html new file mode 100644 index 0000000..2c43ff4 --- /dev/null +++ b/app/templates/admin/link_detail.html @@ -0,0 +1,101 @@ + + + + + + {{ link.title }} — {{ brand }} + + + + + + +
+
+
+

{{ link.title }}

+ +
+ ← Назад +
+ +

+ {% 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 %} +

+ +
+

Активации

+ + + + + + + + + + + {% for claim in claims %} + + + + + + + {% else %} + + + + {% endfor %} + +
КогдаПользовательUUIDДетали
{{ claim.claimed_at }}{{ claim.username }}{{ claim.user_uuid or "—" }}{{ claim.detail }}
Активаций ещё не было
+
+ + {% if link.link_type == "traffic" %} +
+

Временный трафик

+ + + + + + + + + + + + {% for g in grants %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ПользовательГБДоСтатусСнят
{{ g.username }}+{{ (g.bytes_added / 1073741824)|round(2) }}{{ g.expires_at[:19] }}{{ g.status }}{% if g.last_error %} err{% endif %}{{ g.reverted_at[:19] if g.reverted_at else "—" }}
Выдач ещё не было
+
+ {% endif %} +
+ + + diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html new file mode 100644 index 0000000..e0feaf7 --- /dev/null +++ b/app/templates/admin/login.html @@ -0,0 +1,32 @@ + + + + + + Вход — {{ brand }} + + + + + + +
+

{{ brand }} Admin

+

Создание гостевых ссылок на продление и трафик

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + +
+
+ + diff --git a/app/templates/guest/claim.html b/app/templates/guest/claim.html new file mode 100644 index 0000000..424214f --- /dev/null +++ b/app/templates/guest/claim.html @@ -0,0 +1,49 @@ + + + + + + {{ link.title }} — {{ brand }} + + + + + + +
+

{{ brand }}

+

{{ link.title }}

+

+ {% if link.link_type == "extend" %} + Направление: продление тарифа
+ Срок: +{{ link.value }} дн. · + Трафик тарифа: {{ link.plan_traffic_gb }} ГБ +
Лимит будет выставлен фиксированно на {{ link.plan_traffic_gb }} ГБ, использованный трафик сбросится. + {% else %} + Направление: временный трафик + +{{ link.value }} ГБ на {{ link.duration_days }} дн. +
После срока лимит автоматически уменьшится на эти ГБ. + {% endif %} +

+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if success %} +
Готово: {{ success }}
+ {% endif %} + + {% if not blocked %} +
+ + +
+ {% endif %} +
+ + diff --git a/app/templates/guest/invalid.html b/app/templates/guest/invalid.html new file mode 100644 index 0000000..cba8766 --- /dev/null +++ b/app/templates/guest/invalid.html @@ -0,0 +1,19 @@ + + + + + + Ссылка недоступна — {{ brand }} + + + + + + +
+

{{ brand }}

+
{{ message }}
+ На главную +
+ + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..599ce0e --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,101 @@ + + + + + + {{ brand }} — защищённый доступ + + + + + + + + +
+ + +
+

{{ brand }}

+

Стабильный VPN-канал без лишнего шума

+

Продление подписки и докупка трафика по гостевой ссылке — быстро и без лишних шагов.

+ +
+
+ +
+
+

Сигнал чистый. Маршрут ваш.

+

Один сервис для подключения, продления и докупки трафика — без кассы в каждом чате.

+
+
+

Скорость без просадок

+

Современные протоколы и маршрутизация через Remnawave — меньше обрывов, больше стабильности.

+
+
+

Продление по ссылке

+

Гостевая ссылка на продление: клиент вводит username и сразу получает дополнительные дни.

+
+
+

Докупка трафика

+

Отдельное направление для ГБ: лимит растёт мгновенно через панель, без ручных правок.

+
+
+
+
+ +
+
+

Три шага до доступа

+

Короткий путь от заявки до рабочего ключа.

+
+
+
01
+
+

Получите конфиг

+

После оплаты вы получаете конфиг подписки — импортируете в клиент за минуту.

+
+
+
+
02
+
+

Подключитесь

+

Включаете профиль и работаете: сайты, стримы, сервисы — через защищённый туннель.

+
+
+
+
03
+
+

Продлите или докупите

+

По гостевой ссылке продлеваете срок или добавляете трафик — это разные направления, без путаницы.

+
+
+
+
+
+ +
+
+

Готовы к чистому каналу?

+ Открыть панель управления +
+
+ + + + + + diff --git a/app/traffic_worker.py b/app/traffic_worker.py new file mode 100644 index 0000000..57377d6 --- /dev/null +++ b/app/traffic_worker.py @@ -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 diff --git a/app/urlutil.py b/app/urlutil.py new file mode 100644 index 0000000..5aa95b4 --- /dev/null +++ b/app/urlutil.py @@ -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("/") diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..1623cd4 --- /dev/null +++ b/caddy/Caddyfile @@ -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 +} diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..d453bb2 --- /dev/null +++ b/docker-compose.build.yml @@ -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 diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml new file mode 100644 index 0000000..e89cf9b --- /dev/null +++ b/docker-compose.hub.yml @@ -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: diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml new file mode 100644 index 0000000..f2e6e8f --- /dev/null +++ b/docker-compose.standalone.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c59b4a1 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..3c79252 --- /dev/null +++ b/nixpacks.toml @@ -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"