diff --git a/.gitignore b/.gitignore index cbde85d..9c86c17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ .venv -configs/config/params_spectralvpn.conf \ No newline at end of file +.env +API/__pycache__ +API/models/__pycache__ +API/routers/__pycache__ +API/schemas/__pycache__ +API/utils/__pycache__ diff --git a/API/Dockerfile b/API/Dockerfile new file mode 100644 index 0000000..338fccb --- /dev/null +++ b/API/Dockerfile @@ -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"] diff --git a/API/main.py b/API/main.py index e4fb7b0..8d013fd 100644 --- a/API/main.py +++ b/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()} diff --git a/API/models/__init__.py b/API/models/__init__.py new file mode 100644 index 0000000..723b0f7 --- /dev/null +++ b/API/models/__init__.py @@ -0,0 +1,7 @@ +from .base import Base +from .user import User +from .server import Server +from .token import AuthToken +from .config import Config + +__all__ = ["Base", "User", "Server", "AuthToken", "Config"] \ No newline at end of file diff --git a/API/models/base.py b/API/models/base.py new file mode 100644 index 0000000..2278416 --- /dev/null +++ b/API/models/base.py @@ -0,0 +1,4 @@ +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/API/models/config.py b/API/models/config.py new file mode 100644 index 0000000..5439141 --- /dev/null +++ b/API/models/config.py @@ -0,0 +1,14 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from .base import Base + +class Config(Base): + __tablename__ = "configs" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + name: Mapped[str] = mapped_column(String(64), nullable=False) + config: Mapped[str] = mapped_column(String(1024), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/API/models/server.py b/API/models/server.py new file mode 100644 index 0000000..e371997 --- /dev/null +++ b/API/models/server.py @@ -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) diff --git a/API/models/token.py b/API/models/token.py new file mode 100644 index 0000000..5d05b09 --- /dev/null +++ b/API/models/token.py @@ -0,0 +1,17 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from .base import Base + +class AuthToken(Base): + __tablename__ = "tokens" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token_hash: Mapped[str] = mapped_column(String(512), index=True, nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked: Mapped[bool] = mapped_column(default=False, nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + def is_valid(self) -> bool: + return not self.revoked and self.expires_at > datetime.utcnow() diff --git a/API/models/user.py b/API/models/user.py new file mode 100644 index 0000000..70e6c4e --- /dev/null +++ b/API/models/user.py @@ -0,0 +1,16 @@ +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func +from .base import Base +from .server import Server + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(256), index=True, nullable=False) + 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 new file mode 100644 index 0000000..e531270 --- /dev/null +++ b/API/requirements.txt @@ -0,0 +1,7 @@ +fastapi[all] +sqlalchemy +psycopg2-binary +asyncpg +passlib +bcrypt==4.3.0 +httpx diff --git a/API/routers/config.py b/API/routers/config.py new file mode 100644 index 0000000..27c91c3 --- /dev/null +++ b/API/routers/config.py @@ -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 diff --git a/API/routers/server.py b/API/routers/server.py new file mode 100644 index 0000000..3369b67 --- /dev/null +++ b/API/routers/server.py @@ -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 \ No newline at end of file diff --git a/API/routers/user.py b/API/routers/user.py new file mode 100644 index 0000000..340ffb3 --- /dev/null +++ b/API/routers/user.py @@ -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"} diff --git a/API/schemas/config.py b/API/schemas/config.py new file mode 100644 index 0000000..50cbea5 --- /dev/null +++ b/API/schemas/config.py @@ -0,0 +1,20 @@ +from datetime import datetime +from pydantic import BaseModel, Field + +class ConfigCreate(BaseModel): + name: str = Field(..., max_length=64) + +class ConfigResponse(BaseModel): + name: str + config: str + created_at: datetime + bytes_used: int = 0 + + class Config: + from_attributes = True + +class ConfigDelete(BaseModel): + name: str + +class ConfigListResponse(BaseModel): + configs: list[ConfigResponse] diff --git a/API/schemas/server.py b/API/schemas/server.py new file mode 100644 index 0000000..f9ff915 --- /dev/null +++ b/API/schemas/server.py @@ -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 diff --git a/API/schemas/user.py b/API/schemas/user.py new file mode 100644 index 0000000..d9a21ff --- /dev/null +++ b/API/schemas/user.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + +class UserCreate(BaseModel): + email: EmailStr = Field(max_length=256) + password: str = Field(min_length=6, max_length=128) + promo_code: str | None = Field(default="", max_length=128) + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class UserTokenRevoke(BaseModel): + token_to_revoke: str + +class UserResponse(BaseModel): + id: int + email: EmailStr + created_at: datetime + access_token: str + token_type: str = "bearer" diff --git a/API/utils.py b/API/utils.py deleted file mode 100644 index 3b3ced0..0000000 --- a/API/utils.py +++ /dev/null @@ -1,253 +0,0 @@ -import sqlite3 -import pandas -import datetime -import hashlib -import json -import uuid -import requests -import random - -class API : - def __init__(self, path: str, host: str, username: str, passwd: str, inbaund_id: int, inbaund_url: str) : - self.con = sqlite3.connect(path) - self.cur = self.con.cursor() - self.cur.execute(""" - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL UNIQUE, - pass_hash TEXT NOT NULL, - register TEXT NOT NULL, - urls TEXT DEFAULT "[]" NOT NULL, - payment TEXT DEFAULT "" - ) - """) - self.con.commit() - - self.x_ui = requests.Session() - self.x_ui.verify = True - self.x_ui.headers.update({ - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (compatible; py3xui-like)" - }) - response = self.x_ui.post(f"{host}/login", json={ - "username": username, - "password": passwd - }) - response.raise_for_status() - self.host = host - - self.inbaund_id = inbaund_id - self.inbaund_url = inbaund_url - - def read_table(self, sql_command: str, params=None) -> dict : - if params == None : - params = () - return json.loads(pandas.read_sql(sql_command, self.con, params=params).to_json()) - - @staticmethod - def get_hash(text: str) -> str: - return hashlib.sha256(text.encode()).hexdigest() - - def response(self, method: str, endpoint: str, **kwargs) : - response = self.x_ui.request(method, f"{self.host}/panel/{endpoint}", **kwargs) - response.raise_for_status() - return response.json() - - def registration(self, email: str, passwd: str) -> dict : - """ - 0 - Success - 1 - Email is busy - """ - date = datetime.datetime.now() - try : - self.cur.execute(""" - INSERT INTO users (email, pass_hash, register) - VALUES (?, ?, ?) - """, (email, self.get_hash(passwd), f"{date.day}-{date.month}-{date.year} {date.hour}:{date.minute}:{date.second}")) - self.con.commit() - except : - return {"code": 1} - data = self.read_table(""" - SELECT id FROM users - WHERE email = ? - """, (email,)) - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email}} - - def close(self) : - self.con.close() - self.x_ui.close() - - def login(self, email: str, passwd: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": json.loads(data["urls"]["0"])}} - - def add_url(self, email: str, passwd: str, url_name: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - 2 - The url already exists - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - urls = json.loads(data["urls"]["0"]) - if url_name in urls : - return {"code": 2} - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - stream = json.loads(inbaund["streamSettings"]) - suid = random.choice(stream["realitySettings"]["shortIds"]) - sni = random.choice(stream["realitySettings"]["serverNames"]) - pbk = stream["realitySettings"]["settings"]["publicKey"] - uid = str(uuid.uuid4()) - - response = self.response("post", "inbound/addClient", json={ - "id": self.inbaund_id, - "settings": json.dumps({ - "clients":[{ - "id": uid, - "flow": "xtls-rprx-vision", - "email": f"{email}-{url_name}", - "limitIp": 0, - "totalGB": 0, - "expiryTime": 0, - "enable": True, - "tgId": "", - "subId": suid, - "comment": "", - "reset": 0 - }] - }) - }) - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - - url = f"vless://{uid}@{self.inbaund_url}?security=reality&pbk={pbk}&fp=random&sni={sni}&sid={suid}&spx=%2F&flow=xtls-rprx-vision#SpectralVPN-{url_name}" - - urls.append(url_name) - - self.cur.execute(""" - UPDATE users SET - urls = ? - WHERE id = ? - """, (json.dumps(urls), int(data["id"]["0"]))) - self.con.commit() - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": json.loads(data["urls"]["0"]), "url": url}} - - def get_url(self, email: str, passwd: str, url_name: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - 2 - The url does not exist - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - urls = json.loads(data["urls"]["0"]) - if not url_name in urls : - return {"code": 2} - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - stream = json.loads(inbaund["streamSettings"]) - suid = random.choice(stream["realitySettings"]["shortIds"]) - sni = random.choice(stream["realitySettings"]["serverNames"]) - pbk = stream["realitySettings"]["settings"]["publicKey"] - client = None - for i in json.loads(inbaund["settings"])["clients"] : - if i["email"] == f"{email}-{url_name}" : - client = i - break - if client == None : - raise TypeError - uid = client["id"] - url = f"vless://{uid}@{self.inbaund_url}?security=reality&pbk={pbk}&fp=random&sni={sni}&sid={suid}&spx=%2F&flow=xtls-rprx-vision#SpectralVPN-{url_name}" - - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": urls, "url": url}} - - def del_url(self, email: str, passwd: str, url_name: str) -> dict : - """ - 0 - Success - 1 - Incorrect email or password - 2 - The url does not exist - """ - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - if data["id"] == {} : - return {"code": 1} - urls = json.loads(data["urls"]["0"]) - if not url_name in urls : - return {"code": 2} - - response = self.response("post", "inbound/list") - inbaund = None - for i in response["obj"] : - if i["id"] == self.inbaund_id : - inbaund = i - break - if inbaund == None : - raise TypeError - client = None - for i in json.loads(inbaund["settings"])["clients"] : - if i["email"] == f"{email}-{url_name}" : - client = i - break - if client == None : - raise TypeError - uid = client["id"] - self.response("post", f"inbound/{self.inbaund_id}/delClient/{uid}") - - urls.remove(url_name) - self.cur.execute(""" - UPDATE users SET - urls = ? - WHERE id = ? - """, (json.dumps(urls), int(data["id"]["0"]))) - self.con.commit() - data = self.read_table(""" - SELECT id, urls FROM users - WHERE email = ? AND pass_hash = ? - """, (email, self.get_hash(passwd))) - return {"code": 0, "data": {"id": int(data["id"]["0"]), "email": email, "urls": urls}} \ No newline at end of file diff --git a/API/utils/auth.py b/API/utils/auth.py new file mode 100644 index 0000000..a3f46ae --- /dev/null +++ b/API/utils/auth.py @@ -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 diff --git a/API/utils/database.py b/API/utils/database.py new file mode 100644 index 0000000..33ba21c --- /dev/null +++ b/API/utils/database.py @@ -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() \ No newline at end of file diff --git a/API/utils/xui.py b/API/utils/xui.py new file mode 100644 index 0000000..71d80a5 --- /dev/null +++ b/API/utils/xui.py @@ -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() diff --git a/Frontend/Dockerfile b/Frontend/Dockerfile new file mode 100644 index 0000000..12f1639 --- /dev/null +++ b/Frontend/Dockerfile @@ -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;"] diff --git a/Frontend/JS/control-panel.js b/Frontend/JS/control-panel.js deleted file mode 100644 index 92f382d..0000000 --- a/Frontend/JS/control-panel.js +++ /dev/null @@ -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 = "

У вас пока нет конфигураций

"; - return; - } - - urls.forEach(name => { - const card = document.createElement("div"); - card.className = "url-card"; - - card.innerHTML = ` -
-
${name}
-
-
- - -
- `; - - 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', () => {}); \ No newline at end of file diff --git a/configs/nginx/spectralvpn.ru.nginx b/Frontend/Nginx/spectralvpn.ru.nginx similarity index 91% rename from configs/nginx/spectralvpn.ru.nginx rename to Frontend/Nginx/spectralvpn.ru.nginx index 69a618b..75259b3 100644 --- a/configs/nginx/spectralvpn.ru.nginx +++ b/Frontend/Nginx/spectralvpn.ru.nginx @@ -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 / { diff --git a/configs/nginx/spectralvpn.ru_http.nginx b/Frontend/Nginx/spectralvpn.ru_http.nginx similarity index 98% rename from configs/nginx/spectralvpn.ru_http.nginx rename to Frontend/Nginx/spectralvpn.ru_http.nginx index 304a955..4203451 100644 --- a/configs/nginx/spectralvpn.ru_http.nginx +++ b/Frontend/Nginx/spectralvpn.ru_http.nginx @@ -3,4 +3,4 @@ server { listen [::]:80; server_name spectralvpn.ru; return 301 https://$host$request_uri; -} \ No newline at end of file +} diff --git a/Frontend/Nginx/spectralvpn_api.nginx b/Frontend/Nginx/spectralvpn_api.nginx new file mode 100644 index 0000000..9f7c97f --- /dev/null +++ b/Frontend/Nginx/spectralvpn_api.nginx @@ -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; + } +} \ No newline at end of file diff --git a/Frontend/Web/JS/control-panel.js b/Frontend/Web/JS/control-panel.js new file mode 100644 index 0000000..a8da9d5 --- /dev/null +++ b/Frontend/Web/JS/control-panel.js @@ -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 = `

+ У вас пока нет конфигураций.
Нажмите кнопку ниже, чтобы создать первую. +

`; + return; + } + + configs.forEach(cfg => { + const trafficGB = (cfg.bytes_used / (1024 ** 3)).toFixed(2); + + const card = document.createElement("div"); + card.className = "url-card"; + card.innerHTML = ` +
+
${cfg.name}
+
Использовано: ${trafficGB} ГБ
+
+
+ + +
+ `; + + 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); \ No newline at end of file diff --git a/Frontend/JS/register.js b/Frontend/Web/JS/register.js similarity index 51% rename from Frontend/JS/register.js rename to Frontend/Web/JS/register.js index acb49ff..017110d 100644 --- a/Frontend/JS/register.js +++ b/Frontend/Web/JS/register.js @@ -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) { diff --git a/Frontend/Styles/control-panel.css b/Frontend/Web/Styles/control-panel.css similarity index 53% rename from Frontend/Styles/control-panel.css rename to Frontend/Web/Styles/control-panel.css index 70d2f66..829b5a7 100644 --- a/Frontend/Styles/control-panel.css +++ b/Frontend/Web/Styles/control-panel.css @@ -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; -} \ No newline at end of file +.hidden { display: none !important; } \ No newline at end of file diff --git a/Frontend/Styles/index.css b/Frontend/Web/Styles/index.css similarity index 100% rename from Frontend/Styles/index.css rename to Frontend/Web/Styles/index.css diff --git a/Frontend/Styles/offer.css b/Frontend/Web/Styles/offer.css similarity index 100% rename from Frontend/Styles/offer.css rename to Frontend/Web/Styles/offer.css diff --git a/Frontend/Styles/register.css b/Frontend/Web/Styles/register.css similarity index 100% rename from Frontend/Styles/register.css rename to Frontend/Web/Styles/register.css diff --git a/Frontend/Web/control-panel.html b/Frontend/Web/control-panel.html new file mode 100644 index 0000000..4df7588 --- /dev/null +++ b/Frontend/Web/control-panel.html @@ -0,0 +1,98 @@ + + + + + + Личный кабинет — SpectralVPN + + + + +
+
+

SpectralVPN

+ +
+ +
+
+

Как подключиться

+
+ + + +
+ +
+
    +
  1. Создайте конфигурацию ниже и скопируйте ссылку.
  2. +
  3. Установите v2rayNG.
  4. +
  5. Нажмите + → Импорт из буфера обмена.
  6. +
  7. Включайте соединение большой кнопкой.
  8. +
+
+ + + + +
+ +

Ваши конфигурации

+
+ + +
+ +
+
+ + + + +
+ + + + \ No newline at end of file diff --git a/Frontend/index.html b/Frontend/Web/index.html similarity index 100% rename from Frontend/index.html rename to Frontend/Web/index.html diff --git a/Frontend/offer.html b/Frontend/Web/offer.html similarity index 100% rename from Frontend/offer.html rename to Frontend/Web/offer.html diff --git a/Frontend/privacy.html b/Frontend/Web/privacy.html similarity index 100% rename from Frontend/privacy.html rename to Frontend/Web/privacy.html diff --git a/Frontend/register.html b/Frontend/Web/register.html similarity index 90% rename from Frontend/register.html rename to Frontend/Web/register.html index 0a350af..7d3dc13 100644 --- a/Frontend/register.html +++ b/Frontend/Web/register.html @@ -21,6 +21,9 @@
+ + +
diff --git a/Frontend/control-panel.html b/Frontend/control-panel.html deleted file mode 100644 index 96f23f4..0000000 --- a/Frontend/control-panel.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - Личный кабинет — SpectralVPN - - - - -
-
-

SpectralVPN

- -
- -
-
-

Как подключиться

- -
- - - -
- -
-
    -
  1. Создайте и скопируйте свою конфигурацию в личном кабинете.
  2. -
  3. Скачайте и установите приложение v2rayNG.
  4. -
  5. Откройте приложение.
  6. -
  7. Нажмите + в правом верхнем углу → Импорт из буфера обмена.
  8. -
  9. Готово! Включайте/выключайте соединение большой кнопкой внизу справа.
  10. -
-
- - - - -
-

Ваши конфигурации

-
- -
- - -
- - - - \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ee2700 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# SpectralVPN + +> Быстрый, лёгкий и современный VPN-сервер с удобным веб-интерфейсом + +![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?logo=javascript&logoColor=black) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white) ![License](https://img.shields.io/github/license/lev4ikysss/SpectralVPN) + +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) diff --git a/configs/config/params.conf b/configs/config/params.conf deleted file mode 100644 index 422f9ef..0000000 --- a/configs/config/params.conf +++ /dev/null @@ -1,7 +0,0 @@ -[api] -path = spectralvpn.db -host = -username = -password = -inbaund_id = 0 -inbaund_url = spectralvpn.ru:442 \ No newline at end of file diff --git a/configs/nginx/spectralvpn_api.nginx b/configs/nginx/spectralvpn_api.nginx deleted file mode 100644 index ecfb3d1..0000000 --- a/configs/nginx/spectralvpn_api.nginx +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/configs/systemd/spectralvpn_api.service b/configs/systemd/spectralvpn_api.service deleted file mode 100644 index eb515f6..0000000 --- a/configs/systemd/spectralvpn_api.service +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2c5836 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirments.txt b/requirments.txt deleted file mode 100644 index d3a29c4..0000000 --- a/requirments.txt +++ /dev/null @@ -1,4 +0,0 @@ -fastapi[all] -pandas -configparser -requests \ No newline at end of file