Refactor API

This commit is contained in:
Lev 2026-04-16 00:07:48 +03:00
parent 9c813cdfbe
commit 8aa4828239
21 changed files with 731 additions and 363 deletions

View file

@ -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()}

7
API/models/__init__.py Normal file
View file

@ -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"]

4
API/models/base.py Normal file
View file

@ -0,0 +1,4 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

14
API/models/config.py Normal file
View file

@ -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)

18
API/models/server.py Normal file
View file

@ -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)

17
API/models/token.py Normal file
View file

@ -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()

16
API/models/user.py Normal file
View file

@ -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")

7
API/requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi[all]
sqlalchemy
psycopg2-binary
asyncpg
passlib
bcrypt==4.3.0
httpx

133
API/routers/config.py Normal file
View file

@ -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

58
API/routers/server.py Normal file
View file

@ -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

136
API/routers/user.py Normal file
View file

@ -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"}

20
API/schemas/config.py Normal file
View file

@ -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]

21
API/schemas/server.py Normal file
View file

@ -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

21
API/schemas/user.py Normal file
View file

@ -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"

View file

@ -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}}

65
API/utils/auth.py Normal file
View file

@ -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

26
API/utils/database.py Normal file
View file

@ -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()

119
API/utils/xui.py Normal file
View file

@ -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()