From 8aa48282396e4252069beb00ad34ce02a4080fea Mon Sep 17 00:00:00 2001 From: Lev Date: Thu, 16 Apr 2026 00:07:48 +0300 Subject: [PATCH] Refactor API --- .gitignore | 6 +- API/main.py | 124 ++++---------------- API/models/__init__.py | 7 ++ API/models/base.py | 4 + API/models/config.py | 14 +++ API/models/server.py | 18 +++ API/models/token.py | 17 +++ API/models/user.py | 16 +++ API/requirements.txt | 7 ++ API/routers/config.py | 133 ++++++++++++++++++++++ API/routers/server.py | 58 ++++++++++ API/routers/user.py | 136 ++++++++++++++++++++++ API/schemas/config.py | 20 ++++ API/schemas/server.py | 21 ++++ API/schemas/user.py | 21 ++++ API/utils.py | 253 ----------------------------------------- API/utils/auth.py | 65 +++++++++++ API/utils/database.py | 26 +++++ API/utils/xui.py | 119 +++++++++++++++++++ docker-compose.yml | 25 ++++ requirments.txt | 4 - 21 files changed, 731 insertions(+), 363 deletions(-) create mode 100644 API/models/__init__.py create mode 100644 API/models/base.py create mode 100644 API/models/config.py create mode 100644 API/models/server.py create mode 100644 API/models/token.py create mode 100644 API/models/user.py create mode 100644 API/requirements.txt create mode 100644 API/routers/config.py create mode 100644 API/routers/server.py create mode 100644 API/routers/user.py create mode 100644 API/schemas/config.py create mode 100644 API/schemas/server.py create mode 100644 API/schemas/user.py delete mode 100644 API/utils.py create mode 100644 API/utils/auth.py create mode 100644 API/utils/database.py create mode 100644 API/utils/xui.py create mode 100644 docker-compose.yml delete mode 100644 requirments.txt diff --git a/.gitignore b/.gitignore index cbde85d..cf397e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .venv -configs/config/params_spectralvpn.conf \ No newline at end of file +.env +API/__pycache__ +API/models/__pycache__ +API/routers/__pycache__ +API/schemas/__pycache__ \ No newline at end of file diff --git a/API/main.py b/API/main.py index e4fb7b0..fc09e84 100644 --- a/API/main.py +++ b/API/main.py @@ -1,109 +1,23 @@ -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 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..4e81090 --- /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, unique=True) + 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..cd8c631 --- /dev/null +++ b/API/models/server.py @@ -0,0 +1,18 @@ +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, unique=True) + 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) + 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..ecd298e --- /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() \ No newline at end of file diff --git a/API/models/user.py b/API/models/user.py new file mode 100644 index 0000000..76527aa --- /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, unique=True) + 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") \ No newline at end of file diff --git a/API/requirements.txt b/API/requirements.txt new file mode 100644 index 0000000..d12fedb --- /dev/null +++ b/API/requirements.txt @@ -0,0 +1,7 @@ +fastapi[all] +sqlalchemy +psycopg2-binary +asyncpg +passlib +bcrypt==4.3.0 +httpx \ No newline at end of file diff --git a/API/routers/config.py b/API/routers/config.py new file mode 100644 index 0000000..a54d36c --- /dev/null +++ b/API/routers/config.py @@ -0,0 +1,133 @@ +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( + inbound_id=server.inbound_id, + 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 and server.inbound_id: + xui = await XUIClient.from_server(server) + client_email_xui = f"{current_user.email}-{body.name}" + await xui.delete_client(server.inbound_id, 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..ea14886 --- /dev/null +++ b/API/routers/server.py @@ -0,0 +1,58 @@ +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)): + ip = request.client.host + existing = await db.execute( + select(Server).where( + (Server.name == body.name) | + (Server.code == body.code) | + (Server.host == ip) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=409, + detail="Name, ip or code is already exists" + ) + new_server = Server( + name=body.name, + code=body.code, + host=ip, + port=body.port, + user=body.user, + password=body.password, + inbound_id=body.inbound_id + ) + 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..5abf56f --- /dev/null +++ b/API/routers/user.py @@ -0,0 +1,136 @@ +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) + ) + 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) or (not verify_password(body.password, user.pass_hash)): + 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..b7d2c9c --- /dev/null +++ b/API/schemas/server.py @@ -0,0 +1,21 @@ +from datetime import datetime +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): + port: int = Field(ge=1, le=65536) + code: str = Field(max_length=128) + inbound_id: int + +class ServerDel(ServerBase): + pass + +class ServerInfo(BaseModel): + id: int + name: str = Field(max_length=64) + code: str + created_at: datetime 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..8ee9eed --- /dev/null +++ b/API/utils/auth.py @@ -0,0 +1,65 @@ +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 +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) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +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..772775d --- /dev/null +++ b/API/utils/database.py @@ -0,0 +1,26 @@ +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( + #Todo Изменить на env, плдключение к db + f"postgresql+asyncpg://spectral:spectral@localhost:5432/spectral", + echo=True, #Todo удалить + 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..cf6fc22 --- /dev/null +++ b/API/utils/xui.py @@ -0,0 +1,119 @@ +import json +import uuid +import random +from httpx import AsyncClient +from fastapi import HTTPException + +class XUIClient: + def __init__(self, base_url: str, username: str, password: str): + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password + self.session = AsyncClient(timeout=20.0) + self.logged_in = False + + @classmethod + async def from_server(cls, server): + #TODO заменить на https + base_url = f"http://{server.host}:{server.port}" + return cls(base_url, server.user, server.password) + + 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, inbound_id: int, client_email: str, display_name: str) -> str: + await self._login() + resp = await self.session.post(f"{self.base_url}/panel/inbound/list") + resp.raise_for_status() + data = resp.json() + inbounds = data.get("obj", []) + inbound = next((i for i in inbounds if i.get("id") == 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", [""]) + server_names = reality.get("serverNames", {}) + suid = random.choice(short_ids) + sni = random.choice(server_names) + pbk = reality.get("settings", {}).get("publicKey", "") + client_uuid = str(uuid.uuid4()) + add_payload = { + "id": inbound_id, + "settings": json.dumps({ + "clients": [{ + "id": client_uuid, + "flow": "xtls-rprx-vision", + "email": client_email, + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": True, + "tgId": "", + "subId": suid, + "comment": "", + "reset": 0 + }] + }) + } + resp = await self.session.post(f"{self.base_url}/panel/inbound/addClient", json=add_payload) + resp.raise_for_status() + host = inbound.get("remark") + config_url = ( + f"vless://{client_uuid}@{host}?" + f"security=reality&" + f"pbk={pbk}&" + f"fp=random&" + f"sni={sni}&" + f"sid={suid}&" + f"spx=%2F&" + f"flow=xtls-rprx-vision#" + f"{display_name}" + ) + return config_url + + 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/getClientTraffics/{client_email}" + ) + resp.raise_for_status() + data = resp.json() + obj = data.get("obj", {}) + return obj.get("down", 0) + obj.get("up", 0) + except: + return 0 + + async def delete_client(self, inbound_id: int, client_email: str): + await self._login() + try: + resp = await self.session.post( + f"{self.base_url}/panel/inbound/{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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..518e6b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + #api: + # Todo + #web: + # Todo + db: + image: postgres:latest + container_name: spectralvpn-db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD", "pg_isready", "-U", "${DB_USER}", "-d", "${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - db-data:/var/lib/postgresql + ports: + - 5432:5432 + +volumes: + db-data: \ No newline at end of file diff --git a/requirments.txt b/requirments.txt deleted file mode 100644 index d3a29c4..0000000 --- a/requirments.txt +++ /dev/null @@ -1,4 +0,0 @@ -fastapi[all] -pandas -configparser -requests \ No newline at end of file