Refactor API
This commit is contained in:
parent
9c813cdfbe
commit
8aa4828239
21 changed files with 731 additions and 363 deletions
124
API/main.py
124
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()}
|
||||
|
|
|
|||
7
API/models/__init__.py
Normal file
7
API/models/__init__.py
Normal 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
4
API/models/base.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
14
API/models/config.py
Normal file
14
API/models/config.py
Normal 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
18
API/models/server.py
Normal 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
17
API/models/token.py
Normal 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
16
API/models/user.py
Normal 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
7
API/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fastapi[all]
|
||||
sqlalchemy
|
||||
psycopg2-binary
|
||||
asyncpg
|
||||
passlib
|
||||
bcrypt==4.3.0
|
||||
httpx
|
||||
133
API/routers/config.py
Normal file
133
API/routers/config.py
Normal 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
58
API/routers/server.py
Normal 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
136
API/routers/user.py
Normal 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
20
API/schemas/config.py
Normal 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
21
API/schemas/server.py
Normal 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
21
API/schemas/user.py
Normal 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"
|
||||
253
API/utils.py
253
API/utils.py
|
|
@ -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
65
API/utils/auth.py
Normal 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
26
API/utils/database.py
Normal 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
119
API/utils/xui.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue