Как подключиться
+-
+
- Создайте конфигурацию ниже и скопируйте ссылку. +
- Установите v2rayNG. +
- Нажмите + → Импорт из буфера обмена. +
- Включайте соединение большой кнопкой. +
diff --git a/.gitignore b/.gitignore index cbde85d..9c86c17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ .venv -configs/config/params_spectralvpn.conf \ No newline at end of file +.env +API/__pycache__ +API/models/__pycache__ +API/routers/__pycache__ +API/schemas/__pycache__ +API/utils/__pycache__ diff --git a/API/Dockerfile b/API/Dockerfile new file mode 100644 index 0000000..338fccb --- /dev/null +++ b/API/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.14.4-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/API/main.py b/API/main.py index e4fb7b0..8d013fd 100644 --- a/API/main.py +++ b/API/main.py @@ -1,109 +1,24 @@ -from fastapi import FastAPI, HTTPException, Query, status -from pydantic import BaseModel, EmailStr -import configparser -import utils +from datetime import datetime +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from utils.database import engine +import models +from routers import server, user, config -app = FastAPI() +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(models.Base.metadata.create_all) + yield + await engine.dispose() -config = configparser.ConfigParser() -config.read('params.conf', encoding='utf-8') -args = [ - config["api"]["path"], - config["api"]["host"], - config["api"]["username"], - config["api"]["password"], - config.getint("api", "inbaund_id"), - config["api"]["inbaund_url"] - ] +app = FastAPI(lifespan=lifespan) -class RegisterBody(BaseModel) : - email : EmailStr - password : str +app.include_router(server.router) +app.include_router(user.router) +app.include_router(config.router) -class LoginBody(BaseModel) : - email : EmailStr - password : str - -class UrlsBody(BaseModel) : - urls_name : str - email : EmailStr - password : str - -@app.post("/registration", status_code=status.HTTP_201_CREATED) -def registration(body: RegisterBody) : - methods = utils.API(*args) - result = methods.registration(body.email, body.password) - methods.close() - if result["code"] == 1: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email is busy." - ) - return result["data"] - -@app.post("/login", status_code=status.HTTP_200_OK) -def login(body: LoginBody) : - methods = utils.API(*args) - answer = methods.login(body.email, body.password) - methods.close() - if answer["code"] == 1 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Incorrect email or password." - ) - else : - return answer["data"] - -@app.post("/add_url", status_code=status.HTTP_200_OK) -def add_url(body: UrlsBody) : - methods = utils.API(*args) - answer = methods.add_url(body.email, body.password, body.urls_name) - methods.close() - if answer["code"] == 1 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Incorrect email or password." - ) - elif answer["code"] == 2 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="The url already exists." - ) - else : - return answer["data"] - -@app.get("/get_url", status_code=status.HTTP_200_OK) -def get_url(email: EmailStr = Query(...), password: str = Query(...), urls_name: str = Query(...)) : - methods = utils.API(*args) - answer = methods.get_url(email, password, urls_name) - methods.close() - if answer["code"] == 1 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Incorrect email or password." - ) - elif answer["code"] == 2 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="The url does not exist" - ) - else : - return answer["data"] - -@app.delete("/del_url", status_code=status.HTTP_200_OK) -def del_url(email: EmailStr = Query(...), password: str = Query(...), urls_name: str = Query(...)) : - methods = utils.API(*args) - answer = methods.del_url(email, password, urls_name) - methods.close() - if answer["code"] == 1 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Incorrect email or password." - ) - elif answer["code"] == 2 : - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="The url does not exist" - ) - else : - return answer["data"] +@app.get("/ping") +async def ping(): + return {"message": "pong", "time": datetime.now().timestamp()} diff --git a/API/models/__init__.py b/API/models/__init__.py new file mode 100644 index 0000000..723b0f7 --- /dev/null +++ b/API/models/__init__.py @@ -0,0 +1,7 @@ +from .base import Base +from .user import User +from .server import Server +from .token import AuthToken +from .config import Config + +__all__ = ["Base", "User", "Server", "AuthToken", "Config"] \ No newline at end of file diff --git a/API/models/base.py b/API/models/base.py new file mode 100644 index 0000000..2278416 --- /dev/null +++ b/API/models/base.py @@ -0,0 +1,4 @@ +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/API/models/config.py b/API/models/config.py new file mode 100644 index 0000000..5439141 --- /dev/null +++ b/API/models/config.py @@ -0,0 +1,14 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from .base import Base + +class Config(Base): + __tablename__ = "configs" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + name: Mapped[str] = mapped_column(String(64), nullable=False) + config: Mapped[str] = mapped_column(String(1024), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/API/models/server.py b/API/models/server.py new file mode 100644 index 0000000..e371997 --- /dev/null +++ b/API/models/server.py @@ -0,0 +1,19 @@ +from datetime import datetime +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from .base import Base + +class Server(Base): + __tablename__ = "servers" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + code: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) + host: Mapped[str] = mapped_column(String(128), nullable=False) + port: Mapped[int] = mapped_column(nullable=False) + user: Mapped[str] = mapped_column(String(64), nullable=False) + password: Mapped[str] = mapped_column(String(64), nullable=False) + inbound_id: Mapped[int] = mapped_column(nullable=False) + version: Mapped[str] = mapped_column(String(16), nullable=False, default="stable") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/API/models/token.py b/API/models/token.py new file mode 100644 index 0000000..5d05b09 --- /dev/null +++ b/API/models/token.py @@ -0,0 +1,17 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from .base import Base + +class AuthToken(Base): + __tablename__ = "tokens" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token_hash: Mapped[str] = mapped_column(String(512), index=True, nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked: Mapped[bool] = mapped_column(default=False, nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + def is_valid(self) -> bool: + return not self.revoked and self.expires_at > datetime.utcnow() diff --git a/API/models/user.py b/API/models/user.py new file mode 100644 index 0000000..70e6c4e --- /dev/null +++ b/API/models/user.py @@ -0,0 +1,16 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func +from .base import Base +from .server import Server + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(256), index=True, nullable=False) + pass_hash: Mapped[str] = mapped_column(String(512), nullable=False) + server_id: Mapped[int] = mapped_column(ForeignKey("servers.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + server: Mapped[Server] = relationship("Server", lazy="selectin") diff --git a/API/requirements.txt b/API/requirements.txt new file mode 100644 index 0000000..e531270 --- /dev/null +++ b/API/requirements.txt @@ -0,0 +1,7 @@ +fastapi[all] +sqlalchemy +psycopg2-binary +asyncpg +passlib +bcrypt==4.3.0 +httpx diff --git a/API/routers/config.py b/API/routers/config.py new file mode 100644 index 0000000..27c91c3 --- /dev/null +++ b/API/routers/config.py @@ -0,0 +1,132 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from models.user import User +from models.config import Config +from schemas.config import ConfigCreate, ConfigResponse, ConfigListResponse, ConfigDelete +from utils.auth import get_current_user +from utils.database import get_db +from utils.xui import XUIClient + +router = APIRouter(prefix="/config") + +@router.post("/add", status_code=201) +async def create_config(body: ConfigCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + existing = await db.execute( + select(Config).where( + Config.user_id == current_user.id, + Config.name == body.name, + Config.deleted_at.is_(None) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=409, + detail="Config name is invalid" + ) + result = await db.execute( + select(User) + .where(User.id == current_user.id) + .options(selectinload(User.server)) + ) + user_with_server = result.scalar_one() + server = user_with_server.server + if not server or server.deleted_at is not None: + raise HTTPException( + status_code=404, + detail="The server not found" + ) + xui = await XUIClient.from_server(server) + client_email_xui = f"{current_user.email}-{body.name}" + display_name = f"SpectralVPN-{body.name}" + config_url = await xui.add_client( + client_email=client_email_xui, + display_name=display_name + ) + new_config = Config( + user_id=current_user.id, + name=body.name, + config=config_url + ) + db.add(new_config) + await db.commit() + await db.refresh(new_config) + return ConfigResponse( + name=new_config.name, + config=new_config.config, + created_at=new_config.created_at, + bytes_used=0 + ) + +@router.get("/get_info") +async def get_configs(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Config).where( + Config.user_id == current_user.id, + Config.deleted_at.is_(None) + ).order_by(Config.created_at.desc()) + ) + configs_db = result.scalars().all() + configs = [] + xui = None + for cfg in configs_db: + bytes_used = 0 + try: + if xui is None: + server_result = await db.execute( + select(User) + .where(User.id == current_user.id) + .options(selectinload(User.server)) + ) + server = server_result.scalar_one().server + xui = await XUIClient.from_server(server) + client_email = f"{current_user.email}-{cfg.name}" + bytes_used = await xui.get_client_traffic(client_email) + except: + pass + configs.append( + ConfigResponse( + name=cfg.name, + config=cfg.config, + created_at=cfg.created_at, + bytes_used=bytes_used + ) + ) + return ConfigListResponse(configs=configs) + +@router.delete("/delete", status_code=204) +async def delete_config(body: ConfigDelete, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Config).where( + Config.user_id == current_user.id, + Config.name == body.name, + Config.deleted_at.is_(None) + ) + ) + config_record = result.scalar_one_or_none() + if not config_record: + raise HTTPException( + status_code=404, + detail="Config not found" + ) + try: + server_result = await db.execute( + select(User) + .where(User.id == current_user.id) + .options(selectinload(User.server)) + ) + server = server_result.scalar_one().server + if server: + xui = await XUIClient.from_server(server) + client_email_xui = f"{current_user.email}-{body.name}" + await xui.delete_client(client_email_xui) + except: + HTTPException( + status_code=500, + detail="Server error" + ) + config_record.deleted_at = datetime.utcnow() + await db.commit() + return None diff --git a/API/routers/server.py b/API/routers/server.py new file mode 100644 index 0000000..3369b67 --- /dev/null +++ b/API/routers/server.py @@ -0,0 +1,57 @@ +from datetime import datetime +from fastapi import APIRouter, Request, Depends, HTTPException +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from models.server import Server +from schemas.server import ServerAdd, ServerDel, ServerInfo +from utils.database import get_db + +router = APIRouter(prefix="/server") + +@router.post("/add", status_code=201) +async def add_server(body: ServerAdd, request: Request, db: AsyncSession = Depends(get_db)): + existing = await db.execute( + select(Server).where( + (Server.name == body.name) | + (Server.code == body.code) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=409, + detail="Nameor code is already exists" + ) + new_server = Server( + name=body.name, + code=body.code, + host=body.host, + port=body.port, + user=body.user, + password=body.password, + inbound_id=body.inbound_id, + version=body.version + ) + db.add(new_server) + await db.commit() + await db.refresh(new_server) + return ServerInfo.model_validate(new_server, from_attributes=True) + +@router.delete("/delete", status_code=204) +async def del_server(body: ServerDel, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Server).where( + (Server.name == body.name) & + (Server.user == body.user) & + (Server.password == body.password) & + (Server.deleted_at.is_(None)) + ) + ) + server = result.scalar_one_or_none() + if not server: + raise HTTPException( + status_code=404, + detail="Server not found, or invalid secure_key" + ) + server.deleted_at = datetime.utcnow() + await db.commit() + return None \ No newline at end of file diff --git a/API/routers/user.py b/API/routers/user.py new file mode 100644 index 0000000..340ffb3 --- /dev/null +++ b/API/routers/user.py @@ -0,0 +1,144 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from schemas.user import UserCreate, UserResponse, UserLogin, UserTokenRevoke +from models.server import Server +from models.user import User +from models.config import Config +from models.token import AuthToken +from utils.database import get_db +from utils.auth import get_password_hash, create_token, verify_password, get_current_user, hash_token +from utils.xui import XUIClient + +router = APIRouter(prefix="/user") + +@router.post("/signup", status_code=201) +async def signup(body: UserCreate, db: AsyncSession = Depends(get_db)): + promo_code = (body.promo_code or "").strip() + result = await db.execute( + select(Server).where( + Server.deleted_at.is_(None), + Server.code == promo_code + ) + ) + server = result.scalar_one_or_none() + if not server: + raise HTTPException( + status_code=404, + detail="Promo-code is not valid" + ) + existing = await db.execute( + select(User).where( + User.email == body.email, + User.deleted_at.is_(None) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=409, + detail="Email is busy" + ) + new_user = User( + email=body.email, + pass_hash= get_password_hash(body.password), + server_id=server.id + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + access_token = await create_token(user_id=new_user.id, db=db) + return UserResponse( + id=new_user.id, + email=new_user.email, + created_at=new_user.created_at, + access_token=access_token + ) + +@router.post("/login", status_code=200) +async def login(body: UserLogin, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(User).where(User.email == body.email) + ) + user = result.scalar_one_or_none() + if not user or user.deleted_at is not None: + raise HTTPException( + status_code=401, + detail="Incorrect email or password" + ) + if not await verify_password(body.password, user.pass_hash, user.id, db): + raise HTTPException( + status_code=401, + detail="Incorrect email or password" + ) + access_token = await create_token(user_id=user.id, db=db) + return UserResponse( + id=user.id, + email=user.email, + created_at=user.created_at, + access_token=access_token + ) + +@router.post("/logout", status_code=200) +async def revoke_token(body: UserTokenRevoke, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + token_to_revoke = body.token_to_revoke.strip() + token_hash = hash_token(token_to_revoke) + result = await db.execute( + select(AuthToken).where( + AuthToken.token_hash == token_hash, + AuthToken.user_id == current_user.id + ) + ) + token_record = result.scalar_one_or_none() + if not token_record: + raise HTTPException( + status_code=404, + detail="Token not found" + ) + if token_record.revoked: + return {"message": "Token already revoked"} + token_record.revoked = True + token_record.revoked_at = datetime.utcnow() + await db.commit() + return {"message": "Token successfully revoked"} + +@router.delete("/delete", status_code=200) +async def delete_user(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + if current_user.deleted_at is not None: + raise HTTPException( + status_code=400, + detail="User is already deleted" + ) + try: + await db.refresh(current_user, ["server"]) + server = current_user.server + if server and server.inbound_id: + xui = await XUIClient.from_server(server) + result = await db.execute( + select(Config).where( + Config.user_id == current_user.id, + Config.deleted_at.is_(None) + ) + ) + user_configs = result.scalars().all() + for cfg in user_configs: + client_email_xui = f"{current_user.email}-{cfg.name}" + try: + await xui.delete_client(server.inbound_id, client_email_xui) + except: + pass + except: + pass + current_user.deleted_at = datetime.utcnow() + await db.execute( + update(AuthToken) + .where(AuthToken.user_id == current_user.id) + .values(revoked=True, revoked_at=datetime.utcnow()) + ) + await db.execute( + update(Config) + .where(Config.user_id == current_user.id, Config.deleted_at.is_(None)) + .values(deleted_at=datetime.utcnow()) + ) + await db.commit() + return {"message": "User has been successfully deleted"} diff --git a/API/schemas/config.py b/API/schemas/config.py new file mode 100644 index 0000000..50cbea5 --- /dev/null +++ b/API/schemas/config.py @@ -0,0 +1,20 @@ +from datetime import datetime +from pydantic import BaseModel, Field + +class ConfigCreate(BaseModel): + name: str = Field(..., max_length=64) + +class ConfigResponse(BaseModel): + name: str + config: str + created_at: datetime + bytes_used: int = 0 + + class Config: + from_attributes = True + +class ConfigDelete(BaseModel): + name: str + +class ConfigListResponse(BaseModel): + configs: list[ConfigResponse] diff --git a/API/schemas/server.py b/API/schemas/server.py new file mode 100644 index 0000000..f9ff915 --- /dev/null +++ b/API/schemas/server.py @@ -0,0 +1,25 @@ +from datetime import datetime +from typing import Literal +from pydantic import BaseModel, Field + +class ServerBase(BaseModel): + name: str = Field(max_length=64) + user: str = Field(max_length=64) + password: str = Field(max_length=64) + +class ServerAdd(ServerBase): + host: str = Field(max_length=128) + port: int = Field(ge=1, le=65536) + code: str = Field(max_length=128) + inbound_id: int + version: Literal["legacy", "stable"] + +class ServerDel(ServerBase): + pass + +class ServerInfo(BaseModel): + id: int + name: str = Field(max_length=64) + code: str + created_at: datetime + version: str diff --git a/API/schemas/user.py b/API/schemas/user.py new file mode 100644 index 0000000..d9a21ff --- /dev/null +++ b/API/schemas/user.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + +class UserCreate(BaseModel): + email: EmailStr = Field(max_length=256) + password: str = Field(min_length=6, max_length=128) + promo_code: str | None = Field(default="", max_length=128) + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class UserTokenRevoke(BaseModel): + token_to_revoke: str + +class UserResponse(BaseModel): + id: int + email: EmailStr + created_at: datetime + access_token: str + token_type: str = "bearer" diff --git a/API/utils.py b/API/utils.py deleted file mode 100644 index 3b3ced0..0000000 --- a/API/utils.py +++ /dev/null @@ -1,253 +0,0 @@ -import sqlite3 -import pandas -import datetime -import hashlib -import json -import uuid -import requests -import random - -class API : - def __init__(self, path: str, host: str, username: str, passwd: str, inbaund_id: int, inbaund_url: str) : - self.con = sqlite3.connect(path) - self.cur = self.con.cursor() - self.cur.execute(""" - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL UNIQUE, - pass_hash TEXT NOT NULL, - register TEXT NOT NULL, - urls TEXT DEFAULT "[]" NOT NULL, - payment TEXT DEFAULT "" - ) - """) - self.con.commit() - - self.x_ui = requests.Session() - self.x_ui.verify = True - self.x_ui.headers.update({ - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (compatible; py3xui-like)" - }) - response = self.x_ui.post(f"{host}/login", json={ - "username": username, - "password": passwd - }) - response.raise_for_status() - self.host = host - - self.inbaund_id = inbaund_id - self.inbaund_url = inbaund_url - - def read_table(self, sql_command: str, params=None) -> dict : - if params == None : - params = () - return json.loads(pandas.read_sql(sql_command, self.con, params=params).to_json()) - - @staticmethod - def get_hash(text: str) -> str: - return hashlib.sha256(text.encode()).hexdigest() - - def response(self, method: str, endpoint: str, **kwargs) : - response = self.x_ui.request(method, f"{self.host}/panel/{endpoint}", **kwargs) - response.raise_for_status() - return response.json() - - def registration(self, email: str, passwd: str) -> dict : - """ - 0 - Success - 1 - Email is busy - """ - date = datetime.datetime.now() - try : - self.cur.execute(""" - INSERT INTO users (email, pass_hash, register) - VALUES (?, ?, ?) - """, (email, self.get_hash(passwd), f"{date.day}-{date.month}-{date.year} {date.hour}:{date.minute}:{date.second}")) - self.con.commit() - except : - return {"code": 1} - data = self.read_table(""" - SELECT id FROM users - WHERE email = ? - """, (email,)) - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email}} - - def close(self) : - self.con.close() - self.x_ui.close() - - def login(self, email: str, passwd: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": json.loads(data["urls"]["0"])}} - - def add_url(self, email: str, passwd: str, url_name: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - 2 - The url already exists - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - urls = json.loads(data["urls"]["0"]) - if url_name in urls : - return {"code": 2} - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - stream = json.loads(inbaund["streamSettings"]) - suid = random.choice(stream["realitySettings"]["shortIds"]) - sni = random.choice(stream["realitySettings"]["serverNames"]) - pbk = stream["realitySettings"]["settings"]["publicKey"] - uid = str(uuid.uuid4()) - - response = self.response("post", "inbound/addClient", json={ - "id": self.inbaund_id, - "settings": json.dumps({ - "clients":[{ - "id": uid, - "flow": "xtls-rprx-vision", - "email": f"{email}-{url_name}", - "limitIp": 0, - "totalGB": 0, - "expiryTime": 0, - "enable": True, - "tgId": "", - "subId": suid, - "comment": "", - "reset": 0 - }] - }) - }) - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - - url = f"vless://{uid}@{self.inbaund_url}?security=reality&pbk={pbk}&fp=random&sni={sni}&sid={suid}&spx=%2F&flow=xtls-rprx-vision#SpectralVPN-{url_name}" - - urls.append(url_name) - - self.cur.execute(""" - UPDATE users SET - urls = ? - WHERE id = ? - """, (json.dumps(urls), int(data["id"]["0"]))) - self.con.commit() - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": json.loads(data["urls"]["0"]), "url": url}} - - def get_url(self, email: str, passwd: str, url_name: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - 2 - The url does not exist - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - urls = json.loads(data["urls"]["0"]) - if not url_name in urls : - return {"code": 2} - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - stream = json.loads(inbaund["streamSettings"]) - suid = random.choice(stream["realitySettings"]["shortIds"]) - sni = random.choice(stream["realitySettings"]["serverNames"]) - pbk = stream["realitySettings"]["settings"]["publicKey"] - client = None - for i in json.loads(inbaund["settings"])["clients"] : - if i["email"] == f"{email}-{url_name}" : - client = i - break - if client == None : - raise TypeError - uid = client["id"] - url = f"vless://{uid}@{self.inbaund_url}?security=reality&pbk={pbk}&fp=random&sni={sni}&sid={suid}&spx=%2F&flow=xtls-rprx-vision#SpectralVPN-{url_name}" - - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": urls, "url": url}} - - def del_url(self, email: str, passwd: str, url_name: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - 2 - The url does not exist - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - urls = json.loads(data["urls"]["0"]) - if not url_name in urls : - return {"code": 2} - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - client = None - for i in json.loads(inbaund["settings"])["clients"] : - if i["email"] == f"{email}-{url_name}" : - client = i - break - if client == None : - raise TypeError - uid = client["id"] - self.response("post", f"inbound/{self.inbaund_id}/delClient/{uid}") - - urls.remove(url_name) - self.cur.execute(""" - UPDATE users SET - urls = ? - WHERE id = ? - """, (json.dumps(urls), int(data["id"]["0"]))) - self.con.commit() - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": urls}} \ No newline at end of file diff --git a/API/utils/auth.py b/API/utils/auth.py new file mode 100644 index 0000000..a3f46ae --- /dev/null +++ b/API/utils/auth.py @@ -0,0 +1,74 @@ +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Annotated +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyHeader +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from passlib.context import CryptContext +from models.token import AuthToken +from models.user import User +from utils.database import get_db + +pwd_context = CryptContext(schemes=["bcrypt"]) +API_KEY_HEADER = APIKeyHeader(name="X-API-KEY", auto_error=False) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +async def verify_password(plain_password: str, hashed_password: str, user_id: int, db: AsyncSession) -> bool: + if hashed_password != "Unknown": + return pwd_context.verify(plain_password, hashed_password) + new_hash = get_password_hash(plain_password) + await db.execute( + update(User) + .where(User.id == user_id) + .values(pass_hash=new_hash) + ) + await db.commit() + return True + +def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode('utf-8')).hexdigest() + +async def create_token(user_id: int, db: AsyncSession) -> str: + raw_token = secrets.token_urlsafe(48) + token_hash = hash_token(raw_token) + new_token = AuthToken( + user_id=user_id, + token_hash=token_hash, + expires_at=datetime.utcnow() + timedelta(days=30) + ) + db.add(new_token) + await db.commit() + await db.refresh(new_token) + return raw_token + +async def get_current_user(api_key: Annotated[str | None, Depends(API_KEY_HEADER)], db: AsyncSession = Depends(get_db)) -> User: + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is not valid" + ) + token_hash = hash_token(api_key) + result = await db.execute( + select(AuthToken).where( + AuthToken.token_hash == token_hash, + AuthToken.revoked == False, + AuthToken.expires_at > datetime.utcnow() + ) + ) + token_record = result.scalar_one_or_none() + if not token_record: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token is expired" + ) + user = await db.get(User, token_record.user_id) + if not user or user.deleted_at is not None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is not found" + ) + return user diff --git a/API/utils/database.py b/API/utils/database.py new file mode 100644 index 0000000..33ba21c --- /dev/null +++ b/API/utils/database.py @@ -0,0 +1,24 @@ +from os import getenv +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker,AsyncEngine + +engine: AsyncEngine = create_async_engine( + f"postgresql+asyncpg://{getenv("DB_USER")}:{getenv("DB_PASSWORD")}@db:5432/{getenv("DB_NAME")}", + pool_pre_ping=True +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + autocommit=False, + autoflush=False, + expire_on_commit=False +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() \ No newline at end of file diff --git a/API/utils/xui.py b/API/utils/xui.py new file mode 100644 index 0000000..71d80a5 --- /dev/null +++ b/API/utils/xui.py @@ -0,0 +1,176 @@ +import json +import uuid +import random +from urllib.parse import quote +from httpx import AsyncClient +from fastapi import HTTPException + +class Configs: + def __init__(self, inbound_id: int, host: str, client_email: str, display_name: str): + self.inbound_id = inbound_id + self.host = host + self.client_email = client_email + self.display_name = display_name + + def legacy_payload(self, data: dict,) -> dict: + inbounds = data.get("obj") + inbound = next((i for i in inbounds if i.get("id") == self.inbound_id), None) + if not inbound: + raise HTTPException( + status_code=404, + detail=f"Server not found" + ) + stream_settings = json.loads(inbound.get("streamSettings")) + reality = stream_settings.get("realitySettings") + short_ids = reality.get("shortIds") + suid = random.choice(short_ids) + client_uuid = str(uuid.uuid4()) + return { + "id": self.inbound_id, + "settings": json.dumps({ + "clients": [{ + "id": client_uuid, + "flow": "xtls-rprx-vision", + "email": self.client_email, + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": True, + "tgId": "", + "subId": suid, + "comment": "", + "reset": 0 + }] + }) + } + + def legacy_config(self, data: dict) -> str: + inbounds = data.get("obj") + inbound = next((i for i in inbounds if i.get("id") == self.inbound_id), None) + if not inbound: + raise HTTPException( + status_code=500, + detail="Server error" + ) + settings = json.loads(inbound.get("settings")) + clients = settings.get("clients") + current_client = next((i for i in clients if i.get("email") == self.client_email), None) + if not current_client: + raise HTTPException( + status_code=500, + detail="Server error" + ) + client_uuid = current_client.get("id") + sub_id = current_client.get("subId") + stream_settings = json.loads(inbound.get("streamSettings")) + reality = stream_settings.get("realitySettings") + server_names = reality.get("serverNames") + setting = reality.get("settings") + pbk = setting.get("publicKey") + fp = setting.get("fingerprint") + spx = quote(setting.get("spiderX"), safe='') + sni = random.choice(server_names) + port = inbound.get("port") + return ( + f"vless://{client_uuid}@{self.host}:{port}?" + f"security=reality&" + f"pbk={pbk}&" + f"fp={fp}&" + f"sni={sni}&" + f"sid={sub_id}&" + f"spx={spx}&" + f"flow=xtls-rprx-vision#" + f"{self.display_name}" + ) + +class XUIClient: + def __init__(self, host: str, base_url: str, username: str, password: str, inbound_id: int, version: str): + self.host = host + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password + self.inbound_id = inbound_id + self.session = AsyncClient(timeout=20.0) + self.logged_in = False + self.version = version + + @classmethod + async def from_server(cls, server): + base_url = f"https://{server.host}:{server.port}" + return cls(server.host, base_url, server.user, server.password, server.inbound_id, server.version) + + async def _login(self): + if self.logged_in: + return + try: + resp = await self.session.post( + f"{self.base_url}/login", + json={"username": self.username, "password": self.password} + ) + resp.raise_for_status() + self.logged_in = True + except: + raise HTTPException( + status_code=500, + detail="Server error" + ) + + async def add_client(self, client_email: str, display_name: str) -> str: + configs = Configs(self.inbound_id, self.host, client_email, display_name) + await self._login() + resp = await self.session.post(f"{self.base_url}/panel/inbound/list") + resp.raise_for_status() + if self.version == "legacy": + resp = await self.session.post(f"{self.base_url}/panel/inbound/addClient", json=configs.legacy_payload(resp.json())) + #New configs here + else: + raise HTTPException( + status_code=500, + detail="Server error" + ) + resp.raise_for_status() + resp = await self.session.post(f"{self.base_url}/panel/inbound/list") + resp.raise_for_status() + if self.version == "legacy": + return configs.legacy_config(resp.json()) + #New configs here + else: + raise HTTPException( + status_code=500, + detail="Server error" + ) + + async def get_client_traffic(self, client_email: str) -> int: + await self._login() + try: + resp = await self.session.post(f"{self.base_url}/panel/inbound/list") + resp.raise_for_status() + data = resp.json() + inbounds = data.get("obj") + for inbound in inbounds: + client_stats = inbound.get("clientStats") + for stat in client_stats: + if stat.get("email") == client_email: + up = stat.get("up") + down = stat.get("down") + return up + down + return 0 + except: + return 0 + + async def delete_client(self, client_email: str): + await self._login() + try: + resp = await self.session.post( + f"{self.base_url}/panel/inbound/{self.inbound_id}/delClientByEmail/{client_email}" + ) + resp.raise_for_status() + return + except: + HTTPException( + status_code=500, + detail="Server error" + ) + + async def close(self): + await self.session.aclose() diff --git a/Frontend/Dockerfile b/Frontend/Dockerfile new file mode 100644 index 0000000..12f1639 --- /dev/null +++ b/Frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:stable-alpine +COPY Web/ /usr/share/nginx/html/ +COPY Nginx/spectralvpn.ru.nginx /etc/nginx/conf.d/spectralvpn.ru.conf +COPY Nginx/spectralvpn.ru_http.nginx /etc/nginx/conf.d/spectralvpn.ru_http.conf +COPY Nginx/spectralvpn_api.nginx /etc/nginx/conf.d/spectralvpn_api.conf +EXPOSE 80 443 +CMD ["nginx", "-g", "daemon off;"] diff --git a/Frontend/JS/control-panel.js b/Frontend/JS/control-panel.js deleted file mode 100644 index 92f382d..0000000 --- a/Frontend/JS/control-panel.js +++ /dev/null @@ -1,225 +0,0 @@ -const API_BASE = "https://spectralvpn.ru:8500"; - -const getCookie = (name) => { - const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); - return match ? decodeURIComponent(match[2]) : null; -}; - -const deleteCookie = (name) => { - document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict`; -}; - -const sha256 = async (str) => { - const buf = new TextEncoder().encode(str); - const hash = await crypto.subtle.digest("SHA-256", buf); - return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join(""); -}; - -const showNotification = (text, color = "cyan") => { - const notification = document.createElement("div"); - notification.textContent = text; - notification.style.cssText = ` - position: fixed; top: 20px; right: 20px; padding: 15px 25px; border-radius: 12px; - z-index: 9999; font-weight: 600; background: ${color === "cyan" ? "#00ffff22" : "#ff444422"}; - color: ${color}; border: 1px solid ${color === "cyan" ? "cyan" : "#f66"}; - `; - document.body.appendChild(notification); - setTimeout(() => notification.remove(), 3000); -}; - -let currentEmail = null; -let currentHash = null; - -const modal = document.getElementById("loginModal"); -const showModal = () => modal.classList.add("active"); -const hideModal = () => modal.classList.remove("active"); - -const showLoginError = (msg) => { - document.getElementById("loginError").textContent = msg; -}; - -const renderUrls = (urls) => { - const container = document.getElementById("urlsList"); - container.innerHTML = ""; - - if (urls.length === 0) { - container.innerHTML = "
У вас пока нет конфигураций
"; - return; - } - - urls.forEach(name => { - const card = document.createElement("div"); - card.className = "url-card"; - - card.innerHTML = ` -
+ У вас пока нет конфигураций.
Нажмите кнопку ниже, чтобы создать первую.
+