Compare commits
10 commits
eb4227ab83
...
821e5a65ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 821e5a65ab | |||
| ecc16a198d | |||
| bc0838213e | |||
| 4c4d325245 | |||
| 11a84f4926 | |||
| 89651a8e81 | |||
| 9987500ac0 | |||
| 0e71fe7abe | |||
| 8aa4828239 | |||
| 9c813cdfbe |
43 changed files with 1438 additions and 936 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,2 +1,7 @@
|
|||
.venv
|
||||
configs/config/params_spectralvpn.conf
|
||||
.env
|
||||
API/__pycache__
|
||||
API/models/__pycache__
|
||||
API/routers/__pycache__
|
||||
API/schemas/__pycache__
|
||||
API/utils/__pycache__
|
||||
|
|
|
|||
7
API/Dockerfile
Normal file
7
API/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM python:3.14.4-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
125
API/main.py
125
API/main.py
|
|
@ -1,109 +1,24 @@
|
|||
from fastapi import FastAPI, HTTPException, Query, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
import configparser
|
||||
import utils
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from utils.database import engine
|
||||
import models
|
||||
from routers import server, user, config
|
||||
|
||||
app = FastAPI()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(models.Base.metadata.create_all)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read('params.conf', encoding='utf-8')
|
||||
args = [
|
||||
config["api"]["path"],
|
||||
config["api"]["host"],
|
||||
config["api"]["username"],
|
||||
config["api"]["password"],
|
||||
config.getint("api", "inbaund_id"),
|
||||
config["api"]["inbaund_url"]
|
||||
]
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
class RegisterBody(BaseModel) :
|
||||
email : EmailStr
|
||||
password : str
|
||||
app.include_router(server.router)
|
||||
app.include_router(user.router)
|
||||
app.include_router(config.router)
|
||||
|
||||
class LoginBody(BaseModel) :
|
||||
email : EmailStr
|
||||
password : str
|
||||
|
||||
class UrlsBody(BaseModel) :
|
||||
urls_name : str
|
||||
email : EmailStr
|
||||
password : str
|
||||
|
||||
@app.post("/registration", status_code=status.HTTP_201_CREATED)
|
||||
def registration(body: RegisterBody) :
|
||||
methods = utils.API(*args)
|
||||
result = methods.registration(body.email, body.password)
|
||||
methods.close()
|
||||
if result["code"] == 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is busy."
|
||||
)
|
||||
return result["data"]
|
||||
|
||||
@app.post("/login", status_code=status.HTTP_200_OK)
|
||||
def login(body: LoginBody) :
|
||||
methods = utils.API(*args)
|
||||
answer = methods.login(body.email, body.password)
|
||||
methods.close()
|
||||
if answer["code"] == 1 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect email or password."
|
||||
)
|
||||
else :
|
||||
return answer["data"]
|
||||
|
||||
@app.post("/add_url", status_code=status.HTTP_200_OK)
|
||||
def add_url(body: UrlsBody) :
|
||||
methods = utils.API(*args)
|
||||
answer = methods.add_url(body.email, body.password, body.urls_name)
|
||||
methods.close()
|
||||
if answer["code"] == 1 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect email or password."
|
||||
)
|
||||
elif answer["code"] == 2 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="The url already exists."
|
||||
)
|
||||
else :
|
||||
return answer["data"]
|
||||
|
||||
@app.get("/get_url", status_code=status.HTTP_200_OK)
|
||||
def get_url(email: EmailStr = Query(...), password: str = Query(...), urls_name: str = Query(...)) :
|
||||
methods = utils.API(*args)
|
||||
answer = methods.get_url(email, password, urls_name)
|
||||
methods.close()
|
||||
if answer["code"] == 1 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect email or password."
|
||||
)
|
||||
elif answer["code"] == 2 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="The url does not exist"
|
||||
)
|
||||
else :
|
||||
return answer["data"]
|
||||
|
||||
@app.delete("/del_url", status_code=status.HTTP_200_OK)
|
||||
def del_url(email: EmailStr = Query(...), password: str = Query(...), urls_name: str = Query(...)) :
|
||||
methods = utils.API(*args)
|
||||
answer = methods.del_url(email, password, urls_name)
|
||||
methods.close()
|
||||
if answer["code"] == 1 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect email or password."
|
||||
)
|
||||
elif answer["code"] == 2 :
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="The url does not exist"
|
||||
)
|
||||
else :
|
||||
return answer["data"]
|
||||
@app.get("/ping")
|
||||
async def ping():
|
||||
return {"message": "pong", "time": datetime.now().timestamp()}
|
||||
|
|
|
|||
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)
|
||||
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)
|
||||
19
API/models/server.py
Normal file
19
API/models/server.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from .base import Base
|
||||
|
||||
class Server(Base):
|
||||
__tablename__ = "servers"
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
code: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
host: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
port: Mapped[int] = mapped_column(nullable=False)
|
||||
user: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
inbound_id: Mapped[int] = mapped_column(nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(16), nullable=False, default="stable")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
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)
|
||||
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
|
||||
132
API/routers/config.py
Normal file
132
API/routers/config.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from models.user import User
|
||||
from models.config import Config
|
||||
from schemas.config import ConfigCreate, ConfigResponse, ConfigListResponse, ConfigDelete
|
||||
from utils.auth import get_current_user
|
||||
from utils.database import get_db
|
||||
from utils.xui import XUIClient
|
||||
|
||||
router = APIRouter(prefix="/config")
|
||||
|
||||
@router.post("/add", status_code=201)
|
||||
async def create_config(body: ConfigCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(
|
||||
select(Config).where(
|
||||
Config.user_id == current_user.id,
|
||||
Config.name == body.name,
|
||||
Config.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Config name is invalid"
|
||||
)
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == current_user.id)
|
||||
.options(selectinload(User.server))
|
||||
)
|
||||
user_with_server = result.scalar_one()
|
||||
server = user_with_server.server
|
||||
if not server or server.deleted_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="The server not found"
|
||||
)
|
||||
xui = await XUIClient.from_server(server)
|
||||
client_email_xui = f"{current_user.email}-{body.name}"
|
||||
display_name = f"SpectralVPN-{body.name}"
|
||||
config_url = await xui.add_client(
|
||||
client_email=client_email_xui,
|
||||
display_name=display_name
|
||||
)
|
||||
new_config = Config(
|
||||
user_id=current_user.id,
|
||||
name=body.name,
|
||||
config=config_url
|
||||
)
|
||||
db.add(new_config)
|
||||
await db.commit()
|
||||
await db.refresh(new_config)
|
||||
return ConfigResponse(
|
||||
name=new_config.name,
|
||||
config=new_config.config,
|
||||
created_at=new_config.created_at,
|
||||
bytes_used=0
|
||||
)
|
||||
|
||||
@router.get("/get_info")
|
||||
async def get_configs(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Config).where(
|
||||
Config.user_id == current_user.id,
|
||||
Config.deleted_at.is_(None)
|
||||
).order_by(Config.created_at.desc())
|
||||
)
|
||||
configs_db = result.scalars().all()
|
||||
configs = []
|
||||
xui = None
|
||||
for cfg in configs_db:
|
||||
bytes_used = 0
|
||||
try:
|
||||
if xui is None:
|
||||
server_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == current_user.id)
|
||||
.options(selectinload(User.server))
|
||||
)
|
||||
server = server_result.scalar_one().server
|
||||
xui = await XUIClient.from_server(server)
|
||||
client_email = f"{current_user.email}-{cfg.name}"
|
||||
bytes_used = await xui.get_client_traffic(client_email)
|
||||
except:
|
||||
pass
|
||||
configs.append(
|
||||
ConfigResponse(
|
||||
name=cfg.name,
|
||||
config=cfg.config,
|
||||
created_at=cfg.created_at,
|
||||
bytes_used=bytes_used
|
||||
)
|
||||
)
|
||||
return ConfigListResponse(configs=configs)
|
||||
|
||||
@router.delete("/delete", status_code=204)
|
||||
async def delete_config(body: ConfigDelete, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Config).where(
|
||||
Config.user_id == current_user.id,
|
||||
Config.name == body.name,
|
||||
Config.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
config_record = result.scalar_one_or_none()
|
||||
if not config_record:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Config not found"
|
||||
)
|
||||
try:
|
||||
server_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == current_user.id)
|
||||
.options(selectinload(User.server))
|
||||
)
|
||||
server = server_result.scalar_one().server
|
||||
if server:
|
||||
xui = await XUIClient.from_server(server)
|
||||
client_email_xui = f"{current_user.email}-{body.name}"
|
||||
await xui.delete_client(client_email_xui)
|
||||
except:
|
||||
HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
config_record.deleted_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return None
|
||||
57
API/routers/server.py
Normal file
57
API/routers/server.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from models.server import Server
|
||||
from schemas.server import ServerAdd, ServerDel, ServerInfo
|
||||
from utils.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/server")
|
||||
|
||||
@router.post("/add", status_code=201)
|
||||
async def add_server(body: ServerAdd, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(
|
||||
select(Server).where(
|
||||
(Server.name == body.name) |
|
||||
(Server.code == body.code)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Nameor code is already exists"
|
||||
)
|
||||
new_server = Server(
|
||||
name=body.name,
|
||||
code=body.code,
|
||||
host=body.host,
|
||||
port=body.port,
|
||||
user=body.user,
|
||||
password=body.password,
|
||||
inbound_id=body.inbound_id,
|
||||
version=body.version
|
||||
)
|
||||
db.add(new_server)
|
||||
await db.commit()
|
||||
await db.refresh(new_server)
|
||||
return ServerInfo.model_validate(new_server, from_attributes=True)
|
||||
|
||||
@router.delete("/delete", status_code=204)
|
||||
async def del_server(body: ServerDel, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Server).where(
|
||||
(Server.name == body.name) &
|
||||
(Server.user == body.user) &
|
||||
(Server.password == body.password) &
|
||||
(Server.deleted_at.is_(None))
|
||||
)
|
||||
)
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Server not found, or invalid secure_key"
|
||||
)
|
||||
server.deleted_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return None
|
||||
144
API/routers/user.py
Normal file
144
API/routers/user.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from schemas.user import UserCreate, UserResponse, UserLogin, UserTokenRevoke
|
||||
from models.server import Server
|
||||
from models.user import User
|
||||
from models.config import Config
|
||||
from models.token import AuthToken
|
||||
from utils.database import get_db
|
||||
from utils.auth import get_password_hash, create_token, verify_password, get_current_user, hash_token
|
||||
from utils.xui import XUIClient
|
||||
|
||||
router = APIRouter(prefix="/user")
|
||||
|
||||
@router.post("/signup", status_code=201)
|
||||
async def signup(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
promo_code = (body.promo_code or "").strip()
|
||||
result = await db.execute(
|
||||
select(Server).where(
|
||||
Server.deleted_at.is_(None),
|
||||
Server.code == promo_code
|
||||
)
|
||||
)
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Promo-code is not valid"
|
||||
)
|
||||
existing = await db.execute(
|
||||
select(User).where(
|
||||
User.email == body.email,
|
||||
User.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Email is busy"
|
||||
)
|
||||
new_user = User(
|
||||
email=body.email,
|
||||
pass_hash= get_password_hash(body.password),
|
||||
server_id=server.id
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
access_token = await create_token(user_id=new_user.id, db=db)
|
||||
return UserResponse(
|
||||
id=new_user.id,
|
||||
email=new_user.email,
|
||||
created_at=new_user.created_at,
|
||||
access_token=access_token
|
||||
)
|
||||
|
||||
@router.post("/login", status_code=200)
|
||||
async def login(body: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == body.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or user.deleted_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
if not await verify_password(body.password, user.pass_hash, user.id, db):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
access_token = await create_token(user_id=user.id, db=db)
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
created_at=user.created_at,
|
||||
access_token=access_token
|
||||
)
|
||||
|
||||
@router.post("/logout", status_code=200)
|
||||
async def revoke_token(body: UserTokenRevoke, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
token_to_revoke = body.token_to_revoke.strip()
|
||||
token_hash = hash_token(token_to_revoke)
|
||||
result = await db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.token_hash == token_hash,
|
||||
AuthToken.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
if not token_record:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Token not found"
|
||||
)
|
||||
if token_record.revoked:
|
||||
return {"message": "Token already revoked"}
|
||||
token_record.revoked = True
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return {"message": "Token successfully revoked"}
|
||||
|
||||
@router.delete("/delete", status_code=200)
|
||||
async def delete_user(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
if current_user.deleted_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="User is already deleted"
|
||||
)
|
||||
try:
|
||||
await db.refresh(current_user, ["server"])
|
||||
server = current_user.server
|
||||
if server and server.inbound_id:
|
||||
xui = await XUIClient.from_server(server)
|
||||
result = await db.execute(
|
||||
select(Config).where(
|
||||
Config.user_id == current_user.id,
|
||||
Config.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
user_configs = result.scalars().all()
|
||||
for cfg in user_configs:
|
||||
client_email_xui = f"{current_user.email}-{cfg.name}"
|
||||
try:
|
||||
await xui.delete_client(server.inbound_id, client_email_xui)
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
current_user.deleted_at = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(AuthToken)
|
||||
.where(AuthToken.user_id == current_user.id)
|
||||
.values(revoked=True, revoked_at=datetime.utcnow())
|
||||
)
|
||||
await db.execute(
|
||||
update(Config)
|
||||
.where(Config.user_id == current_user.id, Config.deleted_at.is_(None))
|
||||
.values(deleted_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "User has been successfully deleted"}
|
||||
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]
|
||||
25
API/schemas/server.py
Normal file
25
API/schemas/server.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class ServerBase(BaseModel):
|
||||
name: str = Field(max_length=64)
|
||||
user: str = Field(max_length=64)
|
||||
password: str = Field(max_length=64)
|
||||
|
||||
class ServerAdd(ServerBase):
|
||||
host: str = Field(max_length=128)
|
||||
port: int = Field(ge=1, le=65536)
|
||||
code: str = Field(max_length=128)
|
||||
inbound_id: int
|
||||
version: Literal["legacy", "stable"]
|
||||
|
||||
class ServerDel(ServerBase):
|
||||
pass
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
id: int
|
||||
name: str = Field(max_length=64)
|
||||
code: str
|
||||
created_at: datetime
|
||||
version: str
|
||||
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}}
|
||||
74
API/utils/auth.py
Normal file
74
API/utils/auth.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from passlib.context import CryptContext
|
||||
from models.token import AuthToken
|
||||
from models.user import User
|
||||
from utils.database import get_db
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"])
|
||||
API_KEY_HEADER = APIKeyHeader(name="X-API-KEY", auto_error=False)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
async def verify_password(plain_password: str, hashed_password: str, user_id: int, db: AsyncSession) -> bool:
|
||||
if hashed_password != "Unknown":
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
new_hash = get_password_hash(plain_password)
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(pass_hash=new_hash)
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
|
||||
|
||||
async def create_token(user_id: int, db: AsyncSession) -> str:
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
token_hash = hash_token(raw_token)
|
||||
new_token = AuthToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.utcnow() + timedelta(days=30)
|
||||
)
|
||||
db.add(new_token)
|
||||
await db.commit()
|
||||
await db.refresh(new_token)
|
||||
return raw_token
|
||||
|
||||
async def get_current_user(api_key: Annotated[str | None, Depends(API_KEY_HEADER)], db: AsyncSession = Depends(get_db)) -> User:
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token is not valid"
|
||||
)
|
||||
token_hash = hash_token(api_key)
|
||||
result = await db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.token_hash == token_hash,
|
||||
AuthToken.revoked == False,
|
||||
AuthToken.expires_at > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
if not token_record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token is expired"
|
||||
)
|
||||
user = await db.get(User, token_record.user_id)
|
||||
if not user or user.deleted_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is not found"
|
||||
)
|
||||
return user
|
||||
24
API/utils/database.py
Normal file
24
API/utils/database.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from os import getenv
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker,AsyncEngine
|
||||
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
f"postgresql+asyncpg://{getenv("DB_USER")}:{getenv("DB_PASSWORD")}@db:5432/{getenv("DB_NAME")}",
|
||||
pool_pre_ping=True
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
176
API/utils/xui.py
Normal file
176
API/utils/xui.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import json
|
||||
import uuid
|
||||
import random
|
||||
from urllib.parse import quote
|
||||
from httpx import AsyncClient
|
||||
from fastapi import HTTPException
|
||||
|
||||
class Configs:
|
||||
def __init__(self, inbound_id: int, host: str, client_email: str, display_name: str):
|
||||
self.inbound_id = inbound_id
|
||||
self.host = host
|
||||
self.client_email = client_email
|
||||
self.display_name = display_name
|
||||
|
||||
def legacy_payload(self, data: dict,) -> dict:
|
||||
inbounds = data.get("obj")
|
||||
inbound = next((i for i in inbounds if i.get("id") == self.inbound_id), None)
|
||||
if not inbound:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Server not found"
|
||||
)
|
||||
stream_settings = json.loads(inbound.get("streamSettings"))
|
||||
reality = stream_settings.get("realitySettings")
|
||||
short_ids = reality.get("shortIds")
|
||||
suid = random.choice(short_ids)
|
||||
client_uuid = str(uuid.uuid4())
|
||||
return {
|
||||
"id": self.inbound_id,
|
||||
"settings": json.dumps({
|
||||
"clients": [{
|
||||
"id": client_uuid,
|
||||
"flow": "xtls-rprx-vision",
|
||||
"email": self.client_email,
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"enable": True,
|
||||
"tgId": "",
|
||||
"subId": suid,
|
||||
"comment": "",
|
||||
"reset": 0
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
def legacy_config(self, data: dict) -> str:
|
||||
inbounds = data.get("obj")
|
||||
inbound = next((i for i in inbounds if i.get("id") == self.inbound_id), None)
|
||||
if not inbound:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
settings = json.loads(inbound.get("settings"))
|
||||
clients = settings.get("clients")
|
||||
current_client = next((i for i in clients if i.get("email") == self.client_email), None)
|
||||
if not current_client:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
client_uuid = current_client.get("id")
|
||||
sub_id = current_client.get("subId")
|
||||
stream_settings = json.loads(inbound.get("streamSettings"))
|
||||
reality = stream_settings.get("realitySettings")
|
||||
server_names = reality.get("serverNames")
|
||||
setting = reality.get("settings")
|
||||
pbk = setting.get("publicKey")
|
||||
fp = setting.get("fingerprint")
|
||||
spx = quote(setting.get("spiderX"), safe='')
|
||||
sni = random.choice(server_names)
|
||||
port = inbound.get("port")
|
||||
return (
|
||||
f"vless://{client_uuid}@{self.host}:{port}?"
|
||||
f"security=reality&"
|
||||
f"pbk={pbk}&"
|
||||
f"fp={fp}&"
|
||||
f"sni={sni}&"
|
||||
f"sid={sub_id}&"
|
||||
f"spx={spx}&"
|
||||
f"flow=xtls-rprx-vision#"
|
||||
f"{self.display_name}"
|
||||
)
|
||||
|
||||
class XUIClient:
|
||||
def __init__(self, host: str, base_url: str, username: str, password: str, inbound_id: int, version: str):
|
||||
self.host = host
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.inbound_id = inbound_id
|
||||
self.session = AsyncClient(timeout=20.0)
|
||||
self.logged_in = False
|
||||
self.version = version
|
||||
|
||||
@classmethod
|
||||
async def from_server(cls, server):
|
||||
base_url = f"https://{server.host}:{server.port}"
|
||||
return cls(server.host, base_url, server.user, server.password, server.inbound_id, server.version)
|
||||
|
||||
async def _login(self):
|
||||
if self.logged_in:
|
||||
return
|
||||
try:
|
||||
resp = await self.session.post(
|
||||
f"{self.base_url}/login",
|
||||
json={"username": self.username, "password": self.password}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self.logged_in = True
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
|
||||
async def add_client(self, client_email: str, display_name: str) -> str:
|
||||
configs = Configs(self.inbound_id, self.host, client_email, display_name)
|
||||
await self._login()
|
||||
resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
|
||||
resp.raise_for_status()
|
||||
if self.version == "legacy":
|
||||
resp = await self.session.post(f"{self.base_url}/panel/inbound/addClient", json=configs.legacy_payload(resp.json()))
|
||||
#New configs here
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
|
||||
resp.raise_for_status()
|
||||
if self.version == "legacy":
|
||||
return configs.legacy_config(resp.json())
|
||||
#New configs here
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
|
||||
async def get_client_traffic(self, client_email: str) -> int:
|
||||
await self._login()
|
||||
try:
|
||||
resp = await self.session.post(f"{self.base_url}/panel/inbound/list")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
inbounds = data.get("obj")
|
||||
for inbound in inbounds:
|
||||
client_stats = inbound.get("clientStats")
|
||||
for stat in client_stats:
|
||||
if stat.get("email") == client_email:
|
||||
up = stat.get("up")
|
||||
down = stat.get("down")
|
||||
return up + down
|
||||
return 0
|
||||
except:
|
||||
return 0
|
||||
|
||||
async def delete_client(self, client_email: str):
|
||||
await self._login()
|
||||
try:
|
||||
resp = await self.session.post(
|
||||
f"{self.base_url}/panel/inbound/{self.inbound_id}/delClientByEmail/{client_email}"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return
|
||||
except:
|
||||
HTTPException(
|
||||
status_code=500,
|
||||
detail="Server error"
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
await self.session.aclose()
|
||||
7
Frontend/Dockerfile
Normal file
7
Frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM nginx:stable-alpine
|
||||
COPY Web/ /usr/share/nginx/html/
|
||||
COPY Nginx/spectralvpn.ru.nginx /etc/nginx/conf.d/spectralvpn.ru.conf
|
||||
COPY Nginx/spectralvpn.ru_http.nginx /etc/nginx/conf.d/spectralvpn.ru_http.conf
|
||||
COPY Nginx/spectralvpn_api.nginx /etc/nginx/conf.d/spectralvpn_api.conf
|
||||
EXPOSE 80 443
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
const API_BASE = "https://spectralvpn.ru:8500";
|
||||
|
||||
const getCookie = (name) => {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
return match ? decodeURIComponent(match[2]) : null;
|
||||
};
|
||||
|
||||
const deleteCookie = (name) => {
|
||||
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict`;
|
||||
};
|
||||
|
||||
const sha256 = async (str) => {
|
||||
const buf = new TextEncoder().encode(str);
|
||||
const hash = await crypto.subtle.digest("SHA-256", buf);
|
||||
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const showNotification = (text, color = "cyan") => {
|
||||
const notification = document.createElement("div");
|
||||
notification.textContent = text;
|
||||
notification.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px; padding: 15px 25px; border-radius: 12px;
|
||||
z-index: 9999; font-weight: 600; background: ${color === "cyan" ? "#00ffff22" : "#ff444422"};
|
||||
color: ${color}; border: 1px solid ${color === "cyan" ? "cyan" : "#f66"};
|
||||
`;
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => notification.remove(), 3000);
|
||||
};
|
||||
|
||||
let currentEmail = null;
|
||||
let currentHash = null;
|
||||
|
||||
const modal = document.getElementById("loginModal");
|
||||
const showModal = () => modal.classList.add("active");
|
||||
const hideModal = () => modal.classList.remove("active");
|
||||
|
||||
const showLoginError = (msg) => {
|
||||
document.getElementById("loginError").textContent = msg;
|
||||
};
|
||||
|
||||
const renderUrls = (urls) => {
|
||||
const container = document.getElementById("urlsList");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (urls.length === 0) {
|
||||
container.innerHTML = "<p style='text-align:center; color:#666;'>У вас пока нет конфигураций</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
urls.forEach(name => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "url-card";
|
||||
|
||||
card.innerHTML = `
|
||||
<div>
|
||||
<div class="url-name">${name}</div>
|
||||
</div>
|
||||
<div class="url-actions">
|
||||
<button class="btn-copy" data-name="${name}">Скопировать</button>
|
||||
<button class="btn-delete" data-name="${name}">Удалить</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector(".btn-copy").onclick = async () => {
|
||||
try {
|
||||
const url = `${API_BASE}/get_url?email=${encodeURIComponent(currentEmail)}&password=${encodeURIComponent(currentHash)}&urls_name=${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.url) {
|
||||
await navigator.clipboard.writeText(data.url);
|
||||
showNotification(`Конфиг "${name}" скопирован!`);
|
||||
} else {
|
||||
showNotification("Ошибка получения конфига", "red");
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification("Нет связи с сервером", "red");
|
||||
}
|
||||
};
|
||||
|
||||
card.querySelector(".btn-delete").onclick = async () => {
|
||||
if (!confirm(`Удалить конфиг "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}/del_url?email=${encodeURIComponent(currentEmail)}&password=${encodeURIComponent(currentHash)}&urls_name=${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
|
||||
if (res.ok) {
|
||||
card.remove();
|
||||
showNotification(`Конфиг "${name}" удалён`, "red");
|
||||
} else {
|
||||
showNotification("Не удалось удалить", "red");
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification("Ошибка сети", "red");
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
};
|
||||
|
||||
const tryAutoLogin = async () => {
|
||||
currentEmail = getCookie("user_email");
|
||||
currentHash = getCookie("user_hash");
|
||||
|
||||
if (!currentEmail || !currentHash) {
|
||||
showModal();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: currentEmail, password: currentHash })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
document.getElementById("userEmail").textContent = currentEmail;
|
||||
document.getElementById("mainContent").classList.remove("hidden");
|
||||
renderUrls(data.urls || []);
|
||||
} else {
|
||||
deleteCookie("user_email");
|
||||
deleteCookie("user_hash");
|
||||
showModal();
|
||||
}
|
||||
} catch (e) {
|
||||
showModal();
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("loginSubmit").onclick = async () => {
|
||||
const email = document.getElementById("loginEmail").value.trim();
|
||||
const pass = document.getElementById("loginPassword").value;
|
||||
|
||||
if (!email || !pass) {
|
||||
showLoginError("Заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = await sha256(pass);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password: hash })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
currentEmail = email;
|
||||
currentHash = hash;
|
||||
document.cookie = `user_email=${encodeURIComponent(email)}; path=/; max-age=2592000; Secure; SameSite=Strict`;
|
||||
document.cookie = `user_hash=${hash}; path=/; max-age=2592000; Secure; SameSite=Strict`;
|
||||
|
||||
document.getElementById("userEmail").textContent = email;
|
||||
hideModal();
|
||||
document.getElementById("mainContent").classList.remove("hidden");
|
||||
renderUrls(data.urls || []);
|
||||
} else {
|
||||
showLoginError("Неверный email или пароль");
|
||||
}
|
||||
} catch (e) {
|
||||
showLoginError("Ошибка подключения");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("addUrlBtn").onclick = async () => {
|
||||
const name = prompt("Введите название конфигурации:");
|
||||
if (!name?.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/add_url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: currentEmail,
|
||||
password: currentHash,
|
||||
urls_name: name.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
renderUrls(data.urls);
|
||||
showNotification(`Конфиг "${name}" создан! Скопируйте через кнопку.`);
|
||||
} else {
|
||||
showNotification("Ошибка: " + (data.detail || "неизвестно"), "red");
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification("Нет связи с сервером", "red");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("logoutBtn").onclick = () => {
|
||||
deleteCookie("user_email");
|
||||
deleteCookie("user_hash");
|
||||
location.reload();
|
||||
};
|
||||
|
||||
document.getElementById("closeModal").onclick = hideModal;
|
||||
|
||||
tryAutoLogin();
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
document.querySelectorAll('.instructions').forEach(el => el.classList.add('hidden'));
|
||||
|
||||
const platform = btn.dataset.platform;
|
||||
const target = document.getElementById(`${platform}-instructions`);
|
||||
if (target) {
|
||||
target.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {});
|
||||
|
|
@ -10,7 +10,7 @@ server {
|
|||
gzip_types text/css text/javascript text/plain application/javascript application/json;
|
||||
gzip_min_length 1000;
|
||||
|
||||
root /var/www/html/spectralvpn.ru;
|
||||
root /usr/share/nginx/html/;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
|
|
@ -3,4 +3,4 @@ server {
|
|||
listen [::]:80;
|
||||
server_name spectralvpn.ru;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
34
Frontend/Nginx/spectralvpn_api.nginx
Normal file
34
Frontend/Nginx/spectralvpn_api.nginx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
server {
|
||||
listen 8000 ssl;
|
||||
listen [::]:8000 ssl;
|
||||
server_name spectralvpn.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/spectralvpn.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spectralvpn.ru/privkey.pem;
|
||||
|
||||
location / {
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' 'https://spectralvpn.ru' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'X-API-KEY, Content-Type, Authorization' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
return 204;
|
||||
}
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' 'https://spectralvpn.ru' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' '*' always; # если нужно читать заголовки ответа
|
||||
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
}
|
||||
}
|
||||
251
Frontend/Web/JS/control-panel.js
Normal file
251
Frontend/Web/JS/control-panel.js
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
const API_BASE = "https://spectralvpn.ru:8000";
|
||||
|
||||
const getToken = () => localStorage.getItem("access_token");
|
||||
|
||||
const showNotification = (message, type = "success") => {
|
||||
const color = type === "success" ? "#00ffff" : "#ff5555";
|
||||
const notif = document.createElement("div");
|
||||
notif.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px; padding: 14px 24px; border-radius: 12px;
|
||||
background: rgba(0,0,0,0.96); border: 1px solid ${color}; color: ${color};
|
||||
z-index: 10000; font-weight: 500; box-shadow: 0 4px 20px rgba(0,0,0,0.6);
|
||||
`;
|
||||
notif.textContent = message;
|
||||
document.body.appendChild(notif);
|
||||
setTimeout(() => notif.remove(), 4500);
|
||||
};
|
||||
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
showLoginModal();
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"X-API-KEY": token,
|
||||
"Content-Type": "application/json",
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("access_token");
|
||||
showNotification("Сессия истекла. Войдите заново.", "error");
|
||||
showLoginModal();
|
||||
return null;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function showLoginModal() {
|
||||
document.getElementById("loginModal").classList.add("active");
|
||||
document.getElementById("mainContent").classList.add("hidden");
|
||||
document.getElementById("userInfo").style.display = "none";
|
||||
}
|
||||
|
||||
function hideLoginModal() {
|
||||
document.getElementById("loginModal").classList.remove("active");
|
||||
document.getElementById("mainContent").classList.remove("hidden");
|
||||
document.getElementById("userInfo").style.display = "flex";
|
||||
}
|
||||
|
||||
async function loadPanel() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
showLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiRequest("/config/get_info", { method: "GET" });
|
||||
if (!res) return;
|
||||
|
||||
if (!res.ok) {
|
||||
showNotification("Ошибка загрузки данных", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById("userEmail").textContent = data.email || "Аккаунт активен";
|
||||
hideLoginModal();
|
||||
renderConfigs(data.configs || []);
|
||||
}
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const container = document.getElementById("urlsList");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (configs.length === 0) {
|
||||
container.innerHTML = `<p style="text-align:center; color:#666; padding:50px 20px; font-size:15px;">
|
||||
У вас пока нет конфигураций.<br>Нажмите кнопку ниже, чтобы создать первую.
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
configs.forEach(cfg => {
|
||||
const trafficGB = (cfg.bytes_used / (1024 ** 3)).toFixed(2);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "url-card";
|
||||
card.innerHTML = `
|
||||
<div class="url-info">
|
||||
<div class="url-name">${cfg.name}</div>
|
||||
<div class="traffic">Использовано: ${trafficGB} ГБ</div>
|
||||
</div>
|
||||
<div class="url-actions">
|
||||
<button class="btn btn-copy" data-config="${cfg.config}">Скопировать</button>
|
||||
<button class="btn btn-delete" data-name="${cfg.name}">Удалить</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector(".btn-copy").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cfg.config);
|
||||
showNotification(`Конфиг "${cfg.name}" скопирован`);
|
||||
} catch (e) {
|
||||
showNotification("Не удалось скопировать", "error");
|
||||
}
|
||||
});
|
||||
|
||||
card.querySelector(".btn-delete").addEventListener("click", async () => {
|
||||
if (!confirm(`Удалить конфигурацию "${cfg.name}"?`)) return;
|
||||
|
||||
const res = await apiRequest("/config/delete", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ name: cfg.name })
|
||||
});
|
||||
|
||||
if (res && res.ok) {
|
||||
showNotification(`Конфиг "${cfg.name}" удалён`);
|
||||
loadPanel();
|
||||
} else {
|
||||
showNotification("Не удалось удалить конфиг", "error");
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("loginSubmit").addEventListener("click", async () => {
|
||||
const email = document.getElementById("loginEmail").value.trim();
|
||||
const password = document.getElementById("loginPassword").value;
|
||||
const errorEl = document.getElementById("loginError");
|
||||
|
||||
errorEl.textContent = "";
|
||||
|
||||
if (!email || !password) {
|
||||
errorEl.textContent = "Введите email и пароль";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/user/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.access_token) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
showNotification("Вход выполнен успешно!");
|
||||
document.getElementById("loginEmail").value = "";
|
||||
document.getElementById("loginPassword").value = "";
|
||||
loadPanel();
|
||||
} else {
|
||||
errorEl.textContent = data.detail || "Неверный email или пароль";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
errorEl.textContent = "Нет связи с сервером. Попробуйте позже.";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("addConfigBtn").addEventListener("click", () => {
|
||||
document.getElementById("addModal").classList.add("active");
|
||||
document.getElementById("configName").focus();
|
||||
});
|
||||
|
||||
document.getElementById("addCancel").addEventListener("click", () => {
|
||||
document.getElementById("addModal").classList.remove("active");
|
||||
document.getElementById("addError").textContent = "";
|
||||
});
|
||||
|
||||
document.getElementById("addSubmit").addEventListener("click", async () => {
|
||||
const name = document.getElementById("configName").value.trim();
|
||||
const errorEl = document.getElementById("addError");
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = "Введите название конфигурации";
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiRequest("/config/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (res && res.ok) {
|
||||
showNotification(`Конфигурация "${name}" создана!`);
|
||||
document.getElementById("addModal").classList.remove("active");
|
||||
document.getElementById("configName").value = "";
|
||||
loadPanel();
|
||||
} else {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
errorEl.textContent = errData.detail || "Ошибка при создании";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
||||
if (!confirm("Выйти из аккаунта?")) return;
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
await fetch(`${API_BASE}/user/logout`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token_to_revoke: token })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
localStorage.removeItem("access_token");
|
||||
showLoginModal();
|
||||
});
|
||||
|
||||
document.getElementById("deleteAccountBtn").addEventListener("click", async () => {
|
||||
if (!confirm("Вы уверены? Это действие необратимо!")) return;
|
||||
|
||||
const res = await apiRequest("/user/delete", { method: "DELETE" });
|
||||
|
||||
if (res && res.ok) {
|
||||
localStorage.removeItem("access_token");
|
||||
alert("Аккаунт успешно удалён.");
|
||||
showLoginModal();
|
||||
} else {
|
||||
showNotification("Не удалось удалить аккаунт", "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll(".tab-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
|
||||
document.querySelectorAll(".instructions").forEach(instr => instr.classList.add("hidden"));
|
||||
document.getElementById(btn.dataset.platform + "-instructions").classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("addModal").addEventListener("click", (e) => {
|
||||
if (e.target === document.getElementById("addModal")) {
|
||||
document.getElementById("addModal").classList.remove("active");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadPanel);
|
||||
|
|
@ -4,26 +4,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
const setError = (fieldId, message) => {
|
||||
const errorEl = document.getElementById(fieldId);
|
||||
const inputEl = errorEl.previousElementSibling;
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.add("active");
|
||||
if (inputEl) inputEl.classList.add("invalid");
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.add("active");
|
||||
}
|
||||
const input = document.getElementById(fieldId.replace("Error", ""));
|
||||
if (input) input.classList.add("invalid");
|
||||
};
|
||||
|
||||
const clearErrors = () => {
|
||||
form.querySelectorAll(".error").forEach(el => {
|
||||
document.querySelectorAll(".error").forEach(el => {
|
||||
el.textContent = "";
|
||||
el.classList.remove("active");
|
||||
});
|
||||
form.querySelectorAll(".invalid").forEach(el => el.classList.remove("invalid"));
|
||||
document.querySelector(".checkbox-container")?.classList.remove("invalid");
|
||||
};
|
||||
|
||||
const sha256 = async (message) => {
|
||||
const msgBuffer = new TextEncoder().encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
document.querySelectorAll(".input").forEach(el => el.classList.remove("invalid"));
|
||||
};
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
|
|
@ -33,59 +27,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const email = document.getElementById("email").value.trim();
|
||||
const password = document.getElementById("password").value;
|
||||
const passwordReply = document.getElementById("password_reply").value;
|
||||
const terms = document.getElementById("terms").checked;
|
||||
|
||||
let hasError = false;
|
||||
const promo_code = document.getElementById("promo_code") ? document.getElementById("promo_code").value.trim() : "";
|
||||
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError("emailError", "Введите корректный email");
|
||||
hasError = true;
|
||||
return;
|
||||
}
|
||||
if (!password || password.length < 6 || !/(?=.*[A-Za-z])(?=.*\d)/.test(password)) {
|
||||
setError("passwordError", "Минимум 6 символов, буквы + цифры");
|
||||
hasError = true;
|
||||
if (!password || password.length < 6) {
|
||||
setError("passwordError", "Пароль минимум 6 символов");
|
||||
return;
|
||||
}
|
||||
if (password !== passwordReply) {
|
||||
setError("passwordReplyError", "Пароли не совпадают");
|
||||
hasError = true;
|
||||
return;
|
||||
}
|
||||
if (!terms) {
|
||||
setError("termsError", "Принять условия обязательно");
|
||||
document.querySelector(".checkbox-container").classList.add("invalid");
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "Создаём аккаунт...";
|
||||
|
||||
try {
|
||||
const passwordHash = await sha256(password);
|
||||
|
||||
const response = await fetch("https://spectralvpn.ru:8500/registration", {
|
||||
const response = await fetch("https://spectralvpn.ru:8000/user/signup", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
password: passwordHash
|
||||
password: password,
|
||||
promo_code: promo_code
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
Cookies.set("user_email", email, { expires: 30, sameSite: "strict" });
|
||||
Cookies.set("user_hash", passwordHash, { expires: 30, sameSite: "strict" });
|
||||
// Сохраняем токен
|
||||
localStorage.setItem("access_token", result.access_token);
|
||||
|
||||
window.location.href = "control-panel.html";
|
||||
} else {
|
||||
if (result.detail === "Email is busy.") {
|
||||
setError("emailError", "Этот email уже зарегистрирован");
|
||||
if (result.detail?.includes("Promo-code")) {
|
||||
setError("promoError", "Неверный промокод");
|
||||
} else if (result.detail?.includes("Email") || result.detail?.includes("busy")) {
|
||||
setError("emailError", "Этот email уже используется");
|
||||
} else {
|
||||
setError("emailError", result.detail || "Ошибка сервера");
|
||||
setError("emailError", result.detail || "Ошибка регистрации");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -1,261 +1,294 @@
|
|||
:root{
|
||||
:root {
|
||||
--bg: #000;
|
||||
--card: #111;
|
||||
--text: #e0e0e0;
|
||||
--accent: cyan;
|
||||
--accent: #00ffff;
|
||||
--border: #333;
|
||||
--danger: #ff4444;
|
||||
}
|
||||
|
||||
*{margin: 0; padding: 0; box-sizing: border-box;}
|
||||
body{background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh;}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container{max-width: 800px; margin: 0 auto; padding: 20px;}
|
||||
.container { max-width: 820px; margin: 0 auto; padding: 20px; }
|
||||
|
||||
header{
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
header h2 {font-size: 24pt;}
|
||||
header h2 b {color: var(--accent);}
|
||||
header h2 { font-size: 28px; }
|
||||
header h2 b { color: var(--accent); }
|
||||
|
||||
.user-info{
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-size: 14pt;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#logoutBtn{
|
||||
#logoutBtn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: 8px 16px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
#logoutBtn:hover{
|
||||
#logoutBtn:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h1{
|
||||
font-size: 28pt;
|
||||
margin-bottom: 30px;
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
margin-bottom: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.urls-list{
|
||||
.urls-list {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
gap: 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.url-card{
|
||||
.url-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: 0.3s;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.url-card:hover{
|
||||
.url-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.2);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.url-name{
|
||||
.url-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.url-name {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.url-actions{
|
||||
.traffic {
|
||||
font-size: 13.5px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.url-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-copy, .btn-delete{
|
||||
padding: 8px 16px;
|
||||
.btn {
|
||||
padding: 9px 18px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: 0.3s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-copy{
|
||||
.btn-copy {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-copy:hover{
|
||||
.btn-copy:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-delete{
|
||||
background: #330000;
|
||||
.btn-delete {
|
||||
background: #2a0a0a;
|
||||
border: 1px solid #800;
|
||||
color: #f88;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.btn-delete:hover{
|
||||
.btn-delete:hover {
|
||||
background: #800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-btn{
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
padding: 18px;
|
||||
background: transparent;
|
||||
border: 2px dashed var(--accent);
|
||||
color: var(--accent);
|
||||
font-size: 16pt;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition:hover{
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
}
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.modal{
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.8);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
.add-btn:hover {
|
||||
background: rgba(0, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.modal.active{display: flex;}
|
||||
|
||||
.modal-content{
|
||||
background: #111;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--accent);
|
||||
.danger-zone {
|
||||
margin-top: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-content input{
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin: 10px 0;
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
.delete-account-btn {
|
||||
background: #3a0a0a;
|
||||
color: var(--danger);
|
||||
border: 1px solid #a00;
|
||||
padding: 12px 28px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.delete-account-btn:hover {
|
||||
background: #600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-content .error{
|
||||
color: #ff6b6b;
|
||||
margin: 10px 0;
|
||||
min-height: 20px;
|
||||
/* Модальное окно */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.buttons{
|
||||
.modal.active { display: flex; }
|
||||
|
||||
.modal-content {
|
||||
background: #111;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.modal-content h2 { margin-bottom: 20px; text-align: center; }
|
||||
|
||||
.modal-content input {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin-bottom: 15px;
|
||||
background: #222;
|
||||
border: 1px solid #555;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
min-height: 22px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.buttons button{
|
||||
.buttons button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#loginSubmit{
|
||||
background: var(--accent);
|
||||
color: black;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#closeModal{
|
||||
background: transparent;
|
||||
border: 1px solid #666;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.how-to-connect{
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.platform-tabs{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn{
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
color: #aaa;
|
||||
padding: 12px 24px;
|
||||
border-radius: 50px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover{
|
||||
border-color: #666;
|
||||
color: #ddd;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.tab-btn.active{
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.instructions{
|
||||
#addSubmit {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#addCancel {
|
||||
background: transparent;
|
||||
border: 1px solid #666;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Табы и инструкции (оставил твои стили, только немного подчистил) */
|
||||
.platform-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
color: #aaa;
|
||||
padding: 11px 22px;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.tab-btn.active,
|
||||
.tab-btn:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: var(--card);
|
||||
border: 1px solid #333;
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
transition: all 0.3s ease;
|
||||
padding: 26px 30px;
|
||||
}
|
||||
|
||||
.instructions ol{
|
||||
.instructions ol {
|
||||
counter-reset: step;
|
||||
list-style: none;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.instructions li{
|
||||
.instructions li {
|
||||
position: relative;
|
||||
padding-left: 38px;
|
||||
margin-bottom: 18px;
|
||||
padding-left: 42px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.instructions li::before{
|
||||
.instructions li::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
top: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #222;
|
||||
|
|
@ -265,37 +298,7 @@ h1{
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.instructions a{
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(0,255,255,0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.instructions a:hover{
|
||||
border-bottom-color: var(--accent);
|
||||
color: #00ffff;
|
||||
}
|
||||
|
||||
.instructions kbd{
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
padding: 3px 7px;
|
||||
font-family: monospace;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.instructions ul{
|
||||
margin: 12px 0 12px 24px;
|
||||
list-style: disc;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.hidden{
|
||||
display: none !important;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
98
Frontend/Web/control-panel.html
Normal file
98
Frontend/Web/control-panel.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Личный кабинет — SpectralVPN</title>
|
||||
<link rel="stylesheet" href="Styles/control-panel.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h2><b>Spectral</b>VPN</h2>
|
||||
<div class="user-info" id="userInfo" style="display: none;">
|
||||
<span id="userEmail"></span>
|
||||
<button id="logoutBtn">Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="mainContent" class="hidden">
|
||||
<div class="how-to-connect">
|
||||
<h1>Как подключиться</h1>
|
||||
<div class="platform-tabs">
|
||||
<button class="tab-btn active" data-platform="android">Android</button>
|
||||
<button class="tab-btn" data-platform="windows">Windows</button>
|
||||
<button class="tab-btn" data-platform="ios">iOS</button>
|
||||
</div>
|
||||
|
||||
<div class="instructions" id="android-instructions">
|
||||
<ol>
|
||||
<li>Создайте конфигурацию ниже и скопируйте ссылку.</li>
|
||||
<li>Установите <a href="https://github.com/2dust/v2rayNG/releases" target="_blank">v2rayNG</a>.</li>
|
||||
<li>Нажмите + → Импорт из буфера обмена.</li>
|
||||
<li>Включайте соединение большой кнопкой.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="instructions hidden" id="windows-instructions">
|
||||
<ol>
|
||||
<li>Создайте конфигурацию и скопируйте ссылку.</li>
|
||||
<li>Установите <a href="https://github.com/qr243vbi/nekobox/releases" target="_blank">NekoBox</a>.</li>
|
||||
<li>Ctrl + V — конфиг добавится автоматически.</li>
|
||||
<li>Включите Системный прокси и/или TUN.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="instructions hidden" id="ios-instructions">
|
||||
<ol>
|
||||
<li>Создайте конфигурацию.</li>
|
||||
<li>Установите <a href="https://apps.apple.com/ru/app/v2raytun/id6476628951" target="_blank">V2RayTun</a>.</li>
|
||||
<li>+ → Импорт из буфера обмена.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Ваши конфигурации</h1>
|
||||
<div class="urls-list" id="urlsList"></div>
|
||||
<button class="add-btn" id="addConfigBtn">+ Добавить конфигурацию</button>
|
||||
|
||||
<div class="danger-zone">
|
||||
<button id="deleteAccountBtn" class="delete-account-btn">Удалить аккаунт навсегда</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal active" id="loginModal">
|
||||
<div class="modal-content">
|
||||
<h2>Вход в аккаунт</h2>
|
||||
<input type="email" id="loginEmail" placeholder="Email" autocomplete="email">
|
||||
<input type="password" id="loginPassword" placeholder="Пароль" autocomplete="current-password">
|
||||
<div class="error" id="loginError"></div>
|
||||
|
||||
<div class="buttons">
|
||||
<button id="loginSubmit">Войти</button>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 20px; color: #888; font-size: 14px;">
|
||||
Нет аккаунта?
|
||||
<a href="register.html" style="color: #00ffff; text-decoration: none;">Зарегистрироваться</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="addModal">
|
||||
<div class="modal-content">
|
||||
<h2>Новая конфигурация</h2>
|
||||
<input type="text" id="configName" placeholder="Название (Телефон, Ноутбук...)" maxlength="64">
|
||||
<div class="error" id="addError"></div>
|
||||
<div class="buttons">
|
||||
<button id="addSubmit">Создать</button>
|
||||
<button id="addCancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="JS/control-panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -21,6 +21,9 @@
|
|||
<label for="password_reply">Повторите пароль</label>
|
||||
<input type="password" id="password_reply" class="input" placeholder="Повторите пароль">
|
||||
<div class="error" id="passwordReplyError"></div>
|
||||
<label for="promo_code">Промокод</label>
|
||||
<input type="text" id="promo_code" class="input" placeholder="Введите прокод">
|
||||
<div class="error" id="promoError"></div>
|
||||
<div class="checkbox-container">
|
||||
<input type="checkbox" id="terms" class="checkbox" required>
|
||||
<label for="terms">Я прочитал и согласен с <a href="offer.html" target="_blank">пользовательским соглашением</a> и <a href="privacy.html" target="_blank">политикой конфиденциальности</a>.</label>
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Личный кабинет — SpectralVPN</title>
|
||||
<link rel="stylesheet" href="Styles/control-panel.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h2><b>Spectral</b>VPN</h2>
|
||||
<div class="user-info" id="userInfo">
|
||||
<span id="userEmail"></span>
|
||||
<button id="logoutBtn">Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="mainContent" class="hidden">
|
||||
<div class="how-to-connect">
|
||||
<h1>Как подключиться</h1>
|
||||
|
||||
<div class="platform-tabs">
|
||||
<button class="tab-btn active" data-platform="android">Android</button>
|
||||
<button class="tab-btn" data-platform="windows">ПК (Windows)</button>
|
||||
<button class="tab-btn" data-platform="ios">iOS</button>
|
||||
</div>
|
||||
|
||||
<div class="instructions" id="android-instructions">
|
||||
<ol>
|
||||
<li>Создайте и скопируйте свою конфигурацию в личном кабинете.</li>
|
||||
<li>Скачайте и установите <a href="https://github.com/2dust/v2rayNG/releases/download/2.0.13/v2rayNG_2.0.13_universal.apk" target="_blank" rel="noopener">приложение v2rayNG</a>.</li>
|
||||
<li>Откройте приложение.</li>
|
||||
<li>Нажмите <b>+</b> в правом верхнем углу → <b>Импорт из буфера обмена</b>.</li>
|
||||
<li><b>Готово!</b> Включайте/выключайте соединение большой кнопкой внизу справа.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="instructions hidden" id="windows-instructions">
|
||||
<ol>
|
||||
<li>Создайте и скопируйте свою конфигурацию в личном кабинете.</li>
|
||||
<li>Скачайте и установите <a href="https://github.com/qr243vbi/nekobox/releases/download/5.10.25/nekobox-5.10.25-windows64-installer.exe" target="_blank" rel="noopener">NekoBox for Windows</a>.</li>
|
||||
<li>Откройте программу.</li>
|
||||
<li>Нажмите <kbd>Ctrl</kbd> + <kbd>V</kbd> — конфигурация добавится автоматически.</li>
|
||||
<li>Выделите добавленную конфигурацию и нажмите <kbd>Enter</kbd>.</li>
|
||||
<li>Вверху включите:
|
||||
<ul>
|
||||
<li><b>Системный прокси</b> — для работы в браузерах</li>
|
||||
<li><b>Режим TUN</b> — для всей системы</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><b>Готово!</b> Вы подключены.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="instructions hidden" id="ios-instructions">
|
||||
<ol>
|
||||
<li>Создайте и скопируйте свою конфигурацию в личном кабинете.</li>
|
||||
<li>Скачайте приложение <a href="https://apps.apple.com/ru/app/v2raytun/id6476628951" target="_blank" rel="noopener">V2RayTun</a> из App Store.</li>
|
||||
<li>Откройте приложение.</li>
|
||||
<li>Нажмите <b>+</b> в правом верхнем углу → <b>Импорт из буфера обмена</b>.</li>
|
||||
<li><b>Готово!</b> Включайте/выключайте соединение центральной кнопкой.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<h1>Ваши конфигурации</h1>
|
||||
<div class="urls-list" id="urlsList"></div>
|
||||
<button class="add-btn" id="addUrlBtn">+ Добавить конфиг</button>
|
||||
</main>
|
||||
|
||||
<div class="modal" id="loginModal">
|
||||
<div class="modal-content">
|
||||
<h2>Вход в аккаунт</h2>
|
||||
<input type="email" id="loginEmail" placeholder="Email" required>
|
||||
<input type="password" id="loginPassword" placeholder="Пароль" required>
|
||||
<div class="error" id="loginError"></div>
|
||||
<div class="buttons">
|
||||
<button id="loginSubmit">Войти</button>
|
||||
<button id="closeModal">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="JS/control-panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# SpectralVPN
|
||||
|
||||
> Быстрый, лёгкий и современный VPN-сервер с удобным веб-интерфейсом
|
||||
|
||||
   
|
||||
|
||||
SpectralVPN — это открытый VPN-решение, состоящее из бэкенда (API) и современного фронтенда. Проект предназначен для быстрого развёртывания собственного VPN-сервера с удобным управлением через веб-панель.
|
||||
|
||||
## ✨ Возможности
|
||||
|
||||
- Современный REST API на Python - Красивый и responsive веб-интерфейс (Frontend) - Поддержка Docker и docker-compose (быстрое развёртывание) - Управление пользователями, конфигурациями и сессиями - Лёгкая расширяемость
|
||||
|
||||
## 🛠 Технологический стек
|
||||
|
||||
- \*\*Backend\*\*: Python + FastAPI (предположительно) - \*\*Frontend\*\*: HTML, CSS, JavaScript (возможно React/Vue или vanilla с современным дизайном) - \*\*Контейнеризация\*\*: Docker + docker-compose - \*\*База данных\*\*: (укажи, если используешь SQLite/PostgreSQL/Redis)
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[api]
|
||||
path = spectralvpn.db
|
||||
host =
|
||||
username =
|
||||
password =
|
||||
inbaund_id = 0
|
||||
inbaund_url = spectralvpn.ru:442
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
server {
|
||||
listen 8500 ssl;
|
||||
listen [::]:8500 ssl;
|
||||
server_name vpn.spectralvpn.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/vpn.spectralvpn.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/vpn.spectralvpn.ru/privkey.pem;
|
||||
|
||||
location / {
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' 'https://spectralvpn.ru';
|
||||
add_header 'Access-Control-Allow-Origin' 'https://vpn.spectralvpn.ru';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type';
|
||||
add_header 'Access-Control-Max-Age' 86400;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
add_header Access-Control-Allow-Origin "https://spectralvpn.ru" always;
|
||||
add_header Access-Control-Allow-Origin "https://vpn.spectralvpn.ru" always;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
[Unit]
|
||||
Description=API SpectralVPN
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
User = root
|
||||
WorkingDirectory=/etc/spectralvpn_api
|
||||
Environment="PATH=/etc/spectralvpn_api/.venv/bin"
|
||||
ExecStart=/etc/spectralvpn_api/.venv/bin/uvicorn main:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=control-group
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
services:
|
||||
api:
|
||||
build: ./API
|
||||
container_name: spectralvpn-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
extra_hosts:
|
||||
- host.docker.internal:172.17.0.1
|
||||
ports:
|
||||
- 8000:8000
|
||||
web:
|
||||
build: ./Frontend
|
||||
container_name: spectralvpn-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
db:
|
||||
image: postgres:latest
|
||||
container_name: spectralvpn-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "${DB_USER}", "-d", "${DB_NAME}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
#TODO Удалить проброс портов
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
fastapi[all]
|
||||
pandas
|
||||
configparser
|
||||
requests
|
||||
Loading…
Add table
Add a link
Reference in a new issue