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