first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 20:36:00 +03:00
co-authored by Cursor
commit bbff3cb10b
35 changed files with 2054 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from app.routers import admin, auth, public
__all__ = ["admin", "auth", "public"]
+250
View File
@@ -0,0 +1,250 @@
import io
import qrcode
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import AdminUser, Protocol, VpnClient, VpnServer
from app.services import vpn as vpn_service
router = APIRouter(prefix="/admin", tags=["admin"])
async def require_admin(request: Request, db: AsyncSession = Depends(get_db)) -> AdminUser | RedirectResponse:
admin_id = request.session.get("admin_id")
if not admin_id:
return RedirectResponse("/admin/login", status_code=status.HTTP_303_SEE_OTHER)
user = await db.get(AdminUser, admin_id)
if not user or not user.is_active:
request.session.clear()
return RedirectResponse("/admin/login", status_code=status.HTTP_303_SEE_OTHER)
return user
def _is_redirect(value: object) -> bool:
return isinstance(value, RedirectResponse)
@router.get("", response_class=HTMLResponse)
async def dashboard(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)):
if _is_redirect(admin):
return admin
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
clients_total = await db.scalar(select(func.count()).select_from(VpnClient)) or 0
clients_active = await db.scalar(
select(func.count()).select_from(VpnClient).where(VpnClient.is_enabled.is_(True))
) or 0
return request.app.state.templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"admin": admin,
"servers": servers,
"clients_total": clients_total,
"clients_active": clients_active,
"app_name": request.app.state.settings.app_name,
},
)
@router.get("/servers", response_class=HTMLResponse)
async def servers_page(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)):
if _is_redirect(admin):
return admin
servers = (
await db.execute(
select(VpnServer).options(selectinload(VpnServer.clients)).order_by(VpnServer.id)
)
).scalars().all()
return request.app.state.templates.TemplateResponse(
"admin/servers.html",
{
"request": request,
"admin": admin,
"servers": servers,
"app_name": request.app.state.settings.app_name,
},
)
@router.post("/servers/{server_id}/sync")
async def sync_server(
server_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
await vpn_service.sync_server_config(db, server_id)
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/servers/{server_id}")
async def update_server(
server_id: int,
public_host: str = Form(...),
public_port: int = Form(...),
dns: str = Form("1.1.1.1"),
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
server = await db.get(VpnServer, server_id)
if server:
server.public_host = public_host.strip()
server.public_port = public_port
server.dns = dns.strip() or "1.1.1.1"
await db.commit()
await vpn_service.sync_server_config(db, server_id)
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/clients", response_class=HTMLResponse)
async def clients_page(
request: Request,
protocol: str | None = None,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
query = select(VpnClient).options(selectinload(VpnClient.server)).order_by(VpnClient.id.desc())
if protocol:
query = query.join(VpnServer).where(VpnServer.protocol == protocol)
clients = (await db.execute(query)).scalars().all()
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
return request.app.state.templates.TemplateResponse(
"admin/clients.html",
{
"request": request,
"admin": admin,
"clients": clients,
"servers": servers,
"filter_protocol": protocol,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name,
"flash": request.query_params.get("flash"),
},
)
@router.post("/clients")
async def create_client(
request: Request,
name: str = Form(...),
server_id: int = Form(...),
notes: str = Form(""),
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
try:
await vpn_service.create_client(db, server_id=server_id, name=name, notes=notes or None)
except Exception as exc: # noqa: BLE001
return RedirectResponse(
f"/admin/clients?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse("/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/clients/{client_id}/toggle")
async def toggle_client(
client_id: int,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
client = await db.get(VpnClient, client_id)
if client:
await vpn_service.set_client_enabled(db, client_id, not client.is_enabled)
return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/clients/{client_id}/delete")
async def remove_client(
client_id: int,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
await vpn_service.delete_client(db, client_id)
return RedirectResponse("/admin/clients?flash=deleted", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/clients/{client_id}", response_class=HTMLResponse)
async def client_detail(
client_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
result = await db.execute(
select(VpnClient).options(selectinload(VpnClient.server)).where(VpnClient.id == client_id)
)
client = result.scalar_one_or_none()
if not client:
return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER)
config = await vpn_service.get_client_config(db, client_id)
return request.app.state.templates.TemplateResponse(
"admin/client_detail.html",
{
"request": request,
"admin": admin,
"client": client,
"config": config,
"app_name": request.app.state.settings.app_name,
},
)
@router.get("/clients/{client_id}/config")
async def download_config(
client_id: int,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
client = await db.get(VpnClient, client_id)
if not client:
return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER)
config = await vpn_service.get_client_config(db, client_id)
filename = f"{client.name}.conf"
return Response(
content=config,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/clients/{client_id}/qr")
async def client_qr(
client_id: int,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
config = await vpn_service.get_client_config(db, client_id)
img = qrcode.make(config)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return StreamingResponse(buf, media_type="image/png")
+47
View File
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.deps import authenticate_admin
router = APIRouter(prefix="/admin", tags=["auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if request.session.get("admin_id"):
return RedirectResponse("/admin", status_code=status.HTTP_303_SEE_OTHER)
return request.app.state.templates.TemplateResponse(
"admin/login.html",
{"request": request, "error": None, "app_name": request.app.state.settings.app_name},
)
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: AsyncSession = Depends(get_db),
):
user = await authenticate_admin(db, username.strip(), password)
if not user:
return request.app.state.templates.TemplateResponse(
"admin/login.html",
{
"request": request,
"error": "Неверный логин или пароль",
"app_name": request.app.state.settings.app_name,
},
status_code=400,
)
request.session["admin_id"] = user.id
request.session["admin_username"] = user.username
return RedirectResponse("/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/admin/login", status_code=status.HTTP_303_SEE_OTHER)
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter(tags=["public"])
@router.get("/", response_class=HTMLResponse)
async def landing(request: Request):
return request.app.state.templates.TemplateResponse(
"landing.html",
{
"request": request,
"app_name": request.app.state.settings.app_name,
},
)
@router.get("/health")
async def health():
return {"status": "ok"}