diff --git a/app/__init__.py b/app/__init__.py index 959e2c19d..012ac7b97 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,15 +1,14 @@ import logging + from apscheduler.schedulers.background import BackgroundScheduler from fastapi import FastAPI, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi.responses import JSONResponse from fastapi.routing import APIRoute -from fastapi_responses import custom_openapi - -from config import DOCS, XRAY_SUBSCRIPTION_PATH, ALLOWED_ORIGINS +from config import ALLOWED_ORIGINS, DOCS, XRAY_SUBSCRIPTION_PATH __version__ = "0.7.0" @@ -17,13 +16,14 @@ title="MarzbanAPI", description="Unified GUI Censorship Resistant Solution Powered by Xray", version=__version__, - docs_url='/docs' if DOCS else None, - redoc_url='/redoc' if DOCS else None + docs_url="/docs" if DOCS else None, + redoc_url="/redoc" if DOCS else None, ) -app.openapi = custom_openapi(app) -scheduler = BackgroundScheduler({'apscheduler.job_defaults.max_instances': 20}, timezone='UTC') -logger = logging.getLogger('uvicorn.error') +scheduler = BackgroundScheduler( + {"apscheduler.job_defaults.max_instances": 20}, timezone="UTC" +) +logger = logging.getLogger("uvicorn.error") app.add_middleware( CORSMiddleware, @@ -53,7 +53,9 @@ def on_startup(): paths = [f"{r.path}/" for r in app.routes] paths.append("/api/") if f"/{XRAY_SUBSCRIPTION_PATH}/" in paths: - raise ValueError(f"you can't use /{XRAY_SUBSCRIPTION_PATH}/ as subscription path it reserved for {app.title}") + raise ValueError( + f"you can't use /{XRAY_SUBSCRIPTION_PATH}/ as subscription path it reserved for {app.title}" + ) scheduler.start() diff --git a/app/routers/admin.py b/app/routers/admin.py index c17b8395a..327ad634e 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -1,16 +1,17 @@ from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.exc import IntegrityError + from app.db import Session, crud, get_db +from app.dependencies import get_admin_by_username, validate_admin from app.models.admin import Admin, AdminCreate, AdminModify, Token +from app.utils import report, responses from app.utils.jwt import create_admin_token -from fastapi import Depends, HTTPException, status, Request, APIRouter -from fastapi.security import OAuth2PasswordRequestForm -from app.utils import report -from app.dependencies import validate_admin, get_admin_by_username from config import LOGIN_NOTIFY_WHITE_LIST -router = APIRouter(tags=['Admin'], prefix='/api') +router = APIRouter(tags=["Admin"], prefix="/api") def get_client_ip(request: Request) -> str: @@ -27,7 +28,7 @@ def get_client_ip(request: Request) -> str: def admin_token( request: Request, form_data: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Authenticate an admin and issue a token.""" client_ip = get_client_ip(request) @@ -42,21 +43,20 @@ def admin_token( ) if client_ip not in LOGIN_NOTIFY_WHITE_LIST: - report.login(form_data.username, '🔒', client_ip, True) - - return Token( - access_token=create_admin_token( - form_data.username, - dbadmin.is_sudo - ) - ) + report.login(form_data.username, "🔒", client_ip, True) + return Token(access_token=create_admin_token(form_data.username, dbadmin.is_sudo)) -@router.post("/admin", response_model=Admin) + +@router.post( + "/admin", + response_model=Admin, + responses={401: responses._401, 403: responses._403, 409: responses._409}, +) def create_admin( new_admin: AdminCreate, db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + admin: Admin = Depends(Admin.check_sudo_admin), ): """Create a new admin if the current admin has sudo privileges.""" try: @@ -68,58 +68,66 @@ def create_admin( return dbadmin -@router.put("/admin/{username}", response_model=Admin) +@router.put( + "/admin/{username}", + response_model=Admin, + responses={401: responses._401, 403: responses._403}, +) def modify_admin( modified_admin: AdminModify, dbadmin: Admin = Depends(get_admin_by_username), db: Session = Depends(get_db), - current_admin: Admin = Depends(Admin.check_sudo_admin) + current_admin: Admin = Depends(Admin.check_sudo_admin), ): """Modify an existing admin's details.""" if (dbadmin.username != current_admin.username) and dbadmin.is_sudo: raise HTTPException( status_code=403, - detail="You're not allowed to edit another sudoer's account. Use marzban-cli instead." + detail="You're not allowed to edit another sudoer's account. Use marzban-cli instead.", ) - + updated_admin = crud.update_admin(db, dbadmin, modified_admin) - + return updated_admin -@router.delete("/admin/{username}") +@router.delete( + "/admin/{username}", + responses={401: responses._401, 403: responses._403}, +) def remove_admin( dbadmin: Admin = Depends(get_admin_by_username), db: Session = Depends(get_db), - current_admin: Admin = Depends(Admin.check_sudo_admin) + current_admin: Admin = Depends(Admin.check_sudo_admin), ): """Remove an admin from the database.""" if dbadmin.is_sudo: raise HTTPException( status_code=403, - detail="You're not allowed to delete sudo accounts. Use marzban-cli instead." + detail="You're not allowed to delete sudo accounts. Use marzban-cli instead.", ) crud.remove_admin(db, dbadmin) - return {"detail": "Admin removed successfully"} -@router.get("/admin", response_model=Admin) -def get_current_admin( - admin: Admin = Depends(Admin.get_current) -): +@router.get("/admin", response_model=Admin, responses={401: responses._401}) +def get_current_admin(admin: Admin = Depends(Admin.get_current)): """Retrieve the current authenticated admin.""" return admin -@router.get("/admins", response_model=List[Admin]) +@router.get( + "/admins", + response_model=List[Admin], + responses={401: responses._401, 403: responses._403}, +) def get_admins( offset: Optional[int] = None, limit: Optional[int] = None, username: Optional[str] = None, db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + admin: Admin = Depends(Admin.check_sudo_admin), ): """Fetch a list of admins with optional filters for pagination and username.""" - return crud.get_admins(db, offset, limit, username) \ No newline at end of file + return crud.get_admins(db, offset, limit, username) diff --git a/app/routers/core.py b/app/routers/core.py index 11e465f95..eda1cd55d 100644 --- a/app/routers/core.py +++ b/app/routers/core.py @@ -3,25 +3,25 @@ import time import commentjson -from fastapi import Depends, HTTPException, WebSocket, APIRouter +from fastapi import APIRouter, Depends, HTTPException, WebSocket from starlette.websockets import WebSocketDisconnect from app import xray from app.db import Session, get_db from app.models.admin import Admin from app.models.core import CoreStats +from app.utils import responses from app.xray import XRayConfig from config import XRAY_JSON -router = APIRouter(tags=['Core'], prefix='/api') +router = APIRouter(tags=["Core"], prefix="/api", responses={401: responses._401}) + @router.websocket("/core/logs") async def core_logs(websocket: WebSocket, db: Session = Depends(get_db)): - token = ( - websocket.query_params.get('token') - or - websocket.headers.get('Authorization', '').removeprefix("Bearer ") - ) + token = websocket.query_params.get("token") or websocket.headers.get( + "Authorization", "" + ).removeprefix("Bearer ") admin = Admin.get_admin(token, db) if not admin: return await websocket.close(reason="Unauthorized", code=4401) @@ -29,18 +29,20 @@ async def core_logs(websocket: WebSocket, db: Session = Depends(get_db)): if not admin.is_sudo: return await websocket.close(reason="You're not allowed", code=4403) - interval = websocket.query_params.get('interval') + interval = websocket.query_params.get("interval") if interval: try: interval = float(interval) except ValueError: return await websocket.close(reason="Invalid interval value", code=4400) if interval > 10: - return await websocket.close(reason="Interval must be more than 0 and at most 10 seconds", code=4400) + return await websocket.close( + reason="Interval must be more than 0 and at most 10 seconds", code=4400 + ) await websocket.accept() - cache = '' + cache = "" last_sent_ts = 0 with xray.core.get_logs() as logs: while True: @@ -49,7 +51,7 @@ async def core_logs(websocket: WebSocket, db: Session = Depends(get_db)): await websocket.send_text(cache) except (WebSocketDisconnect, RuntimeError): break - cache = '' + cache = "" last_sent_ts = time.time() if not logs: @@ -64,7 +66,7 @@ async def core_logs(websocket: WebSocket, db: Session = Depends(get_db)): log = logs.popleft() if interval: - cache += f'{log}\n' + cache += f"{log}\n" continue try: @@ -72,21 +74,19 @@ async def core_logs(websocket: WebSocket, db: Session = Depends(get_db)): except (WebSocketDisconnect, RuntimeError): break + @router.get("/core", response_model=CoreStats) -def get_core_stats( - admin: Admin = Depends(Admin.get_current) -): +def get_core_stats(admin: Admin = Depends(Admin.get_current)): """Retrieve core statistics such as version and uptime.""" return CoreStats( version=xray.core.version, started=xray.core.started, - logs_websocket=router.url_path_for('core_logs') + logs_websocket=router.url_path_for("core_logs"), ) -@router.post("/core/restart") -def restart_core( - admin: Admin = Depends(Admin.check_sudo_admin) -): + +@router.post("/core/restart", responses={403: responses._403}) +def restart_core(admin: Admin = Depends(Admin.check_sudo_admin)): """Restart the core and all connected nodes.""" startup_config = xray.config.include_db_users() xray.core.restart(startup_config) @@ -94,20 +94,20 @@ def restart_core( for node_id, node in list(xray.nodes.items()): if node.connected: xray.operations.restart_node(node_id, startup_config) - + return {} -@router.get("/core/config") -def get_core_config( - admin: Admin = Depends(Admin.check_sudo_admin) -) -> dict: + +@router.get("/core/config", responses={403: responses._403}) +def get_core_config(admin: Admin = Depends(Admin.check_sudo_admin)) -> dict: """Get the current core configuration.""" with open(XRAY_JSON, "r") as f: config = commentjson.loads(f.read()) return config -@router.put("/core/config") + +@router.put("/core/config", responses={403: responses._403}) def modify_core_config( payload: dict, admin: Admin = Depends(Admin.check_sudo_admin) ) -> dict: @@ -118,7 +118,7 @@ def modify_core_config( raise HTTPException(status_code=400, detail=str(err)) xray.config = config - with open(XRAY_JSON, 'w') as f: + with open(XRAY_JSON, "w") as f: f.write(json.dumps(payload, indent=4)) startup_config = xray.config.include_db_users() diff --git a/app/routers/node.py b/app/routers/node.py index e32b50882..b32f4c474 100644 --- a/app/routers/node.py +++ b/app/routers/node.py @@ -1,21 +1,29 @@ import asyncio import time -from datetime import datetime, timedelta, timezone from typing import List +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, WebSocket from sqlalchemy.exc import IntegrityError -from fastapi import BackgroundTasks, Depends, HTTPException, WebSocket, APIRouter, Query from starlette.websockets import WebSocketDisconnect from app import logger, xray from app.db import Session, crud, get_db +from app.dependencies import get_dbnode, validate_dates from app.models.admin import Admin -from app.models.node import (NodeCreate, NodeModify, NodeResponse, - NodeSettings, NodeStatus, NodesUsageResponse) +from app.models.node import ( + NodeCreate, + NodeModify, + NodeResponse, + NodeSettings, + NodeStatus, + NodesUsageResponse, +) from app.models.proxy import ProxyHost -from app.dependencies import get_dbnode, validate_dates +from app.utils import responses -router = APIRouter(tags=['Node'], prefix='/api') +router = APIRouter( + tags=["Node"], prefix="/api", responses={401: responses._401, 403: responses._403} +) def add_host_if_needed(new_node: NodeCreate, db: Session): @@ -23,7 +31,7 @@ def add_host_if_needed(new_node: NodeCreate, db: Session): if new_node.add_as_new_host: host = ProxyHost( remark=f"{new_node.name} ({{USERNAME}}) [{{PROTOCOL}} - {{TRANSPORT}}]", - address=new_node.address + address=new_node.address, ) for inbound_tag in xray.config.inbounds_by_tag: crud.add_host(db, inbound_tag, host) @@ -32,39 +40,40 @@ def add_host_if_needed(new_node: NodeCreate, db: Session): @router.get("/node/settings", response_model=NodeSettings) def get_node_settings( - db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + db: Session = Depends(get_db), admin: Admin = Depends(Admin.check_sudo_admin) ): """Retrieve the current node settings, including TLS certificate.""" tls = crud.get_tls_certificate(db) return NodeSettings(certificate=tls.certificate) -@router.post("/node", response_model=NodeResponse) +@router.post("/node", response_model=NodeResponse, responses={409: responses._409}) def add_node( new_node: NodeCreate, bg: BackgroundTasks, db: Session = Depends(get_db), - _: Admin = Depends(Admin.check_sudo_admin) + _: Admin = Depends(Admin.check_sudo_admin), ): """Add a new node to the database and optionally add it as a host.""" try: dbnode = crud.create_node(db, new_node) except IntegrityError: db.rollback() - raise HTTPException(status_code=409, detail=f"Node \"{new_node.name}\" already exists") + raise HTTPException( + status_code=409, detail=f'Node "{new_node.name}" already exists' + ) bg.add_task(xray.operations.connect_node, node_id=dbnode.id) bg.add_task(add_host_if_needed, new_node, db) - logger.info(f"New node \"{dbnode.name}\" added") + logger.info(f'New node "{dbnode.name}" added') return dbnode @router.get("/node/{node_id}", response_model=NodeResponse) def get_node( dbnode: NodeResponse = Depends(get_dbnode), - _: Admin = Depends(Admin.check_sudo_admin) + _: Admin = Depends(Admin.check_sudo_admin), ): """Retrieve details of a specific node by its ID.""" return dbnode @@ -72,11 +81,9 @@ def get_node( @router.websocket("/node/{node_id}/logs") async def node_logs(node_id: int, websocket: WebSocket, db: Session = Depends(get_db)): - token = ( - websocket.query_params.get('token') - or - websocket.headers.get('Authorization', '').removeprefix("Bearer ") - ) + token = websocket.query_params.get("token") or websocket.headers.get( + "Authorization", "" + ).removeprefix("Bearer ") admin = Admin.get_admin(token, db) if not admin: return await websocket.close(reason="Unauthorized", code=4401) @@ -90,18 +97,20 @@ async def node_logs(node_id: int, websocket: WebSocket, db: Session = Depends(ge if not xray.nodes[node_id].connected: return await websocket.close(reason="Node is not connected", code=4400) - interval = websocket.query_params.get('interval') + interval = websocket.query_params.get("interval") if interval: try: interval = float(interval) except ValueError: return await websocket.close(reason="Invalid interval value", code=4400) if interval > 10: - return await websocket.close(reason="Interval must be more than 0 and at most 10 seconds", code=4400) + return await websocket.close( + reason="Interval must be more than 0 and at most 10 seconds", code=4400 + ) await websocket.accept() - cache = '' + cache = "" last_sent_ts = 0 node = xray.nodes[node_id] with node.get_logs() as logs: @@ -114,7 +123,7 @@ async def node_logs(node_id: int, websocket: WebSocket, db: Session = Depends(ge await websocket.send_text(cache) except (WebSocketDisconnect, RuntimeError): break - cache = '' + cache = "" last_sent_ts = time.time() if not logs: @@ -129,7 +138,7 @@ async def node_logs(node_id: int, websocket: WebSocket, db: Session = Depends(ge log = logs.popleft() if interval: - cache += f'{log}\n' + cache += f"{log}\n" continue try: @@ -140,8 +149,7 @@ async def node_logs(node_id: int, websocket: WebSocket, db: Session = Depends(ge @router.get("/nodes", response_model=List[NodeResponse]) def get_nodes( - db: Session = Depends(get_db), - _: Admin = Depends(Admin.check_sudo_admin) + db: Session = Depends(get_db), _: Admin = Depends(Admin.check_sudo_admin) ): """Retrieve a list of all nodes. Accessible only to sudo admins.""" return crud.get_nodes(db) @@ -153,7 +161,7 @@ def modify_node( bg: BackgroundTasks, dbnode: NodeResponse = Depends(get_node), db: Session = Depends(get_db), - _: Admin = Depends(Admin.check_sudo_admin) + _: Admin = Depends(Admin.check_sudo_admin), ): """Update a node's details. Only accessible to sudo admins.""" updated_node = crud.update_node(db, dbnode, modified_node) @@ -161,18 +169,18 @@ def modify_node( if updated_node.status != NodeStatus.disabled: bg.add_task(xray.operations.connect_node, node_id=updated_node.id) - logger.info(f"Node \"{dbnode.name}\" modified") + logger.info(f'Node "{dbnode.name}" modified') return dbnode @router.post("/node/{node_id}/reconnect") -def reconnect_node( +def reconnect_node( bg: BackgroundTasks, dbnode: NodeResponse = Depends(get_node), - _: Admin = Depends(Admin.check_sudo_admin) + _: Admin = Depends(Admin.check_sudo_admin), ): """Trigger a reconnection for the specified node. Only accessible to sudo admins.""" - bg.add_task(xray.operations.connect_node,node_id=dbnode.id) + bg.add_task(xray.operations.connect_node, node_id=dbnode.id) return {"detail": "Reconnection task scheduled"} @@ -180,13 +188,13 @@ def reconnect_node( def remove_node( dbnode: NodeResponse = Depends(get_node), db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + admin: Admin = Depends(Admin.check_sudo_admin), ): """Delete a node and remove it from xray in the background.""" crud.remove_node(db, dbnode) xray.operations.remove_node(dbnode.id) - logger.info(f"Node \"{dbnode.name}\" deleted") + logger.info(f'Node "{dbnode.name}" deleted') return {} @@ -195,11 +203,11 @@ def get_usage( db: Session = Depends(get_db), start: str = "", end: str = "", - _: Admin = Depends(Admin.check_sudo_admin) + _: Admin = Depends(Admin.check_sudo_admin), ): """Retrieve usage statistics for nodes within a specified date range.""" start, end = validate_dates(start, end) usages = crud.get_nodes_usage(db, start, end) - return {"usages": usages} \ No newline at end of file + return {"usages": usages} diff --git a/app/routers/system.py b/app/routers/system.py index d3244597a..8330f622e 100644 --- a/app/routers/system.py +++ b/app/routers/system.py @@ -1,19 +1,22 @@ from typing import Dict, List, Union -from fastapi import Depends, HTTPException, APIRouter -from app import xray, __version__ + +from fastapi import APIRouter, Depends, HTTPException + +from app import __version__, xray from app.db import Session, crud, get_db from app.models.admin import Admin from app.models.proxy import ProxyHost, ProxyInbound, ProxyTypes from app.models.system import SystemStats from app.models.user import UserStatus -from app.utils.system import memory_usage, cpu_usage, realtime_bandwidth +from app.utils import responses +from app.utils.system import cpu_usage, memory_usage, realtime_bandwidth + +router = APIRouter(tags=["System"], prefix="/api", responses={401: responses._401}) -router = APIRouter(tags=['System'], prefix='/api') @router.get("/system", response_model=SystemStats) def get_system_stats( - db: Session = Depends(get_db), - admin: Admin = Depends(Admin.get_current) + db: Session = Depends(get_db), admin: Admin = Depends(Admin.get_current) ): """Fetch system stats including memory, CPU, and user metrics.""" mem = memory_usage() @@ -22,7 +25,9 @@ def get_system_stats( dbadmin: Union[Admin, None] = crud.get_admin(db, admin.username) total_user = crud.get_users_count(db, admin=dbadmin if not admin.is_sudo else None) - users_active = crud.get_users_count(db, status=UserStatus.active, admin=dbadmin if not admin.is_sudo else None) + users_active = crud.get_users_count( + db, status=UserStatus.active, admin=dbadmin if not admin.is_sudo else None + ) realtime_bandwidth_stats = realtime_bandwidth() return SystemStats( @@ -39,27 +44,38 @@ def get_system_stats( outgoing_bandwidth_speed=realtime_bandwidth_stats.outgoing_bytes, ) -@router.get('/inbounds', response_model=Dict[ProxyTypes, List[ProxyInbound]]) + +@router.get("/inbounds", response_model=Dict[ProxyTypes, List[ProxyInbound]]) def get_inbounds(admin: Admin = Depends(Admin.get_current)): """Retrieve inbound configurations grouped by protocol.""" return xray.config.inbounds_by_protocol -@router.get('/hosts', response_model=Dict[str, List[ProxyHost]]) -def get_hosts(db: Session = Depends(get_db), admin: Admin = Depends(Admin.check_sudo_admin)): + +@router.get( + "/hosts", response_model=Dict[str, List[ProxyHost]], responses={403: responses._403} +) +def get_hosts( + db: Session = Depends(get_db), admin: Admin = Depends(Admin.check_sudo_admin) +): """Get a list of proxy hosts grouped by inbound tag.""" hosts = {tag: crud.get_hosts(db, tag) for tag in xray.config.inbounds_by_tag} return hosts -@router.put('/hosts', response_model=Dict[str, List[ProxyHost]]) + +@router.put( + "/hosts", response_model=Dict[str, List[ProxyHost]], responses={403: responses._403} +) def modify_hosts( modified_hosts: Dict[str, List[ProxyHost]], db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + admin: Admin = Depends(Admin.check_sudo_admin), ): """Modify proxy hosts and update the configuration.""" for inbound_tag in modified_hosts: if inbound_tag not in xray.config.inbounds_by_tag: - raise HTTPException(status_code=400, detail=f"Inbound {inbound_tag} doesn't exist") + raise HTTPException( + status_code=400, detail=f"Inbound {inbound_tag} doesn't exist" + ) for inbound_tag, hosts in modified_hosts.items(): crud.update_hosts(db, inbound_tag, hosts) diff --git a/app/routers/user.py b/app/routers/user.py index 4446fc6b6..a1e0fec62 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -1,26 +1,33 @@ from datetime import datetime, timedelta, timezone -from typing import List, Union, Optional +from typing import List, Optional, Union +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from sqlalchemy.exc import IntegrityError -from fastapi import BackgroundTasks, Depends, HTTPException, Query, APIRouter from app import logger, xray from app.db import Session, crud, get_db +from app.dependencies import get_expired_users_list, get_validated_user, validate_dates from app.models.admin import Admin -from app.models.user import (UserCreate, UserModify, UserResponse, - UsersResponse, UserStatus, UserUsagesResponse, UsersUsagesResponse) -from app.utils import report -from app.dependencies import get_validated_user, validate_dates, get_expired_users_list +from app.models.user import ( + UserCreate, + UserModify, + UserResponse, + UsersResponse, + UserStatus, + UsersUsagesResponse, + UserUsagesResponse, +) +from app.utils import report, responses -router = APIRouter(tags=['User'], prefix='/api') +router = APIRouter(tags=["User"], prefix="/api", responses={401: responses._401}) -@router.post("/user", response_model=UserResponse) +@router.post("/user", response_model=UserResponse, responses={400: responses._400, 409: responses._409}) def add_user( new_user: UserCreate, bg: BackgroundTasks, db: Session = Depends(get_db), - admin: Admin = Depends(Admin.get_current) + admin: Admin = Depends(Admin.get_current), ): """ Add a new user @@ -42,36 +49,32 @@ def add_user( for proxy_type in new_user.proxies: if not xray.config.inbounds_by_protocol.get(proxy_type): raise HTTPException( - status_code=400, detail=f"Protocol {proxy_type} is disabled on your server") + status_code=400, + detail=f"Protocol {proxy_type} is disabled on your server", + ) try: - dbuser = crud.create_user(db, new_user, - admin=crud.get_admin(db, admin.username)) + dbuser = crud.create_user( + db, new_user, admin=crud.get_admin(db, admin.username) + ) except IntegrityError: db.rollback() raise HTTPException(status_code=409, detail="User already exists") bg.add_task(xray.operations.add_user, dbuser=dbuser) user = UserResponse.from_orm(dbuser) - report.user_created( - user=user, - user_id=dbuser.id, - by=admin, - user_admin=dbuser.admin - ) - logger.info(f"New user \"{dbuser.username}\" added") + report.user_created(user=user, user_id=dbuser.id, by=admin, user_admin=dbuser.admin) + logger.info(f'New user "{dbuser.username}" added') return user -@router.get("/user/{username}", response_model=UserResponse) -def get_user( - dbuser: UserResponse = Depends(get_validated_user) -): +@router.get("/user/{username}", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) +def get_user(dbuser: UserResponse = Depends(get_validated_user)): """Get user information""" return dbuser -@router.put("/user/{username}", response_model=UserResponse) +@router.put("/user/{username}", response_model=UserResponse, responses={400: responses._400,403: responses._403, 404: responses._404}) def modify_user( modified_user: UserModify, bg: BackgroundTasks, @@ -99,7 +102,8 @@ def modify_user( for proxy_type in modified_user.proxies: if not xray.config.inbounds_by_protocol.get(proxy_type): raise HTTPException( - status_code=400, detail=f"Protocol {proxy_type} is disabled on your server" + status_code=400, + detail=f"Protocol {proxy_type} is disabled on your server", ) old_status = dbuser.status @@ -111,14 +115,9 @@ def modify_user( else: bg.add_task(xray.operations.remove_user, dbuser=dbuser) - bg.add_task( - report.user_updated, - user=user, - user_admin=dbuser.admin, - by=admin - ) + bg.add_task(report.user_updated, user=user, user_admin=dbuser.admin, by=admin) - logger.info(f"User \"{user.username}\" modified") + logger.info(f'User "{user.username}" modified') if user.status != old_status: bg.add_task( @@ -127,16 +126,16 @@ def modify_user( status=user.status, user=user, user_admin=dbuser.admin, - by=admin + by=admin, ) logger.info( - f"User \"{dbuser.username}\" status changed from {old_status} to {user.status}" + f'User "{dbuser.username}" status changed from {old_status} to {user.status}' ) return user -@router.delete("/user/{username}") +@router.delete("/user/{username}", responses={403: responses._403, 404: responses._404}) def remove_user( bg: BackgroundTasks, db: Session = Depends(get_db), @@ -148,17 +147,14 @@ def remove_user( bg.add_task(xray.operations.remove_user, dbuser=dbuser) bg.add_task( - report.user_deleted, - username=dbuser.username, - user_admin=dbuser.admin, - by=admin + report.user_deleted, username=dbuser.username, user_admin=dbuser.admin, by=admin ) - logger.info(f"User \"{dbuser.username}\" deleted") + logger.info(f'User "{dbuser.username}" deleted') return {"detail": "User successfully deleted"} -@router.post("/user/{username}/reset", response_model=UserResponse) +@router.post("/user/{username}/reset", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) def reset_user_data_usage( bg: BackgroundTasks, db: Session = Depends(get_db), @@ -171,21 +167,20 @@ def reset_user_data_usage( bg.add_task(xray.operations.add_user, dbuser=dbuser) user = UserResponse.from_orm(dbuser) - bg.add_task(report.user_data_usage_reset, - user=user, - user_admin=dbuser.admin, - by=admin) + bg.add_task( + report.user_data_usage_reset, user=user, user_admin=dbuser.admin, by=admin + ) - logger.info(f"User \"{dbuser.username}\"'s usage was reset") + logger.info(f'User "{dbuser.username}"\'s usage was reset') return dbuser -@router.post("/user/{username}/revoke_sub", response_model=UserResponse) +@router.post("/user/{username}/revoke_sub", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) def revoke_user_subscription( bg: BackgroundTasks, db: Session = Depends(get_db), dbuser: UserResponse = Depends(get_validated_user), - admin: Admin = Depends(Admin.get_current) + admin: Admin = Depends(Admin.get_current), ): """Revoke users subscription (Subscription link and proxies)""" dbuser = crud.revoke_user_sub(db=db, dbuser=dbuser) @@ -194,18 +189,15 @@ def revoke_user_subscription( bg.add_task(xray.operations.update_user, dbuser=dbuser) user = UserResponse.from_orm(dbuser) bg.add_task( - report.user_subscription_revoked, - user=user, - user_admin=dbuser.admin, - by=admin + report.user_subscription_revoked, user=user, user_admin=dbuser.admin, by=admin ) - logger.info(f"User \"{dbuser.username}\" subscription revoked") + logger.info(f'User "{dbuser.username}" subscription revoked') return user -@router.get("/users", response_model=UsersResponse) +@router.get("/users", response_model=UsersResponse, responses={400: responses._400, 403: responses._403, 404: responses._404}) def get_users( offset: int = None, limit: int = None, @@ -215,36 +207,38 @@ def get_users( status: UserStatus = None, sort: str = None, db: Session = Depends(get_db), - admin: Admin = Depends(Admin.get_current) + admin: Admin = Depends(Admin.get_current), ): """Get all users""" if sort is not None: - opts = sort.strip(',').split(',') + opts = sort.strip(",").split(",") sort = [] for opt in opts: try: sort.append(crud.UsersSortingOptions[opt]) except KeyError: - raise HTTPException(status_code=400, - detail=f'"{opt}" is not a valid sort option') - - users, count = crud.get_users(db=db, - offset=offset, - limit=limit, - search=search, - usernames=username, - status=status, - sort=sort, - admins=owner if admin.is_sudo else [admin.username], - return_with_count=True) + raise HTTPException( + status_code=400, detail=f'"{opt}" is not a valid sort option' + ) + + users, count = crud.get_users( + db=db, + offset=offset, + limit=limit, + search=search, + usernames=username, + status=status, + sort=sort, + admins=owner if admin.is_sudo else [admin.username], + return_with_count=True, + ) return {"users": users, "total": count} -@router.post("/users/reset") +@router.post("/users/reset", responses={403: responses._403, 404: responses._404}) def reset_users_data_usage( - db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + db: Session = Depends(get_db), admin: Admin = Depends(Admin.check_sudo_admin) ): """Reset all users data usage""" dbadmin = crud.get_admin(db, admin.username) @@ -257,12 +251,12 @@ def reset_users_data_usage( return {"detail": "Users successfully reset."} -@router.get("/user/{username}/usage", response_model=UserUsagesResponse) +@router.get("/user/{username}/usage", response_model=UserUsagesResponse,responses={403: responses._403, 404: responses._404}) def get_user_usage( dbuser: UserResponse = Depends(get_validated_user), start: str = "", end: str = "", - db: Session = Depends(get_db) + db: Session = Depends(get_db), ): """Get users usage""" start, end = validate_dates(start, end) @@ -278,16 +272,13 @@ def get_users_usage( end: str = "", db: Session = Depends(get_db), owner: Union[List[str], None] = Query(None, alias="admin"), - admin: Admin = Depends(Admin.get_current) + admin: Admin = Depends(Admin.get_current), ): """Get all users usage""" start, end = validate_dates(start, end) usages = crud.get_all_users_usages( - db=db, - start=start, - end=end, - admin=owner if admin.is_sudo else [admin.username] + db=db, start=start, end=end, admin=owner if admin.is_sudo else [admin.username] ) return {"usages": usages} @@ -298,7 +289,7 @@ def set_owner( admin_username: str, dbuser: UserResponse = Depends(get_validated_user), db: Session = Depends(get_db), - admin: Admin = Depends(Admin.check_sudo_admin) + admin: Admin = Depends(Admin.check_sudo_admin), ): """Set a new owner (admin) for a user.""" new_admin = crud.get_admin(db, username=admin_username) @@ -308,7 +299,7 @@ def set_owner( dbuser = crud.set_owner(db, dbuser, new_admin) user = UserResponse.from_orm(dbuser) - logger.info(f"{user.username}\"owner successfully set to{admin.username}") + logger.info(f'{user.username}"owner successfully set to{admin.username}') return user @@ -318,9 +309,8 @@ def get_expired_users( expired_after: Optional[datetime] = Query(None, example="2024-01-01T00:00:00"), expired_before: Optional[datetime] = Query(None, example="2024-01-31T23:59:59"), db: Session = Depends(get_db), - admin: Admin = Depends(Admin.get_current) + admin: Admin = Depends(Admin.get_current), ): - """ Get users who have expired within the specified date range. @@ -342,7 +332,7 @@ def delete_expired_users( expired_after: Optional[datetime] = Query(None, example="2024-01-01T00:00:00"), expired_before: Optional[datetime] = Query(None, example="2024-01-31T23:59:59"), db: Session = Depends(get_db), - admin: Admin = Depends(Admin.get_current) + admin: Admin = Depends(Admin.get_current), ): """ Delete users who have expired within the specified date range. @@ -352,17 +342,26 @@ def delete_expired_users( - At least one of expired_after or expired_before must be provided """ expired_after, expired_before = validate_dates(expired_after, expired_before) - + expired_users = get_expired_users_list(db, admin, expired_after, expired_before) removed_users = [u.username for u in expired_users] if not removed_users: - raise HTTPException(status_code=404, detail="No expired users found in the specified date range") + raise HTTPException( + status_code=404, detail="No expired users found in the specified date range" + ) crud.remove_users(db, expired_users) for removed_user in removed_users: - logger.info(f"User \"{removed_user}\" deleted") - bg.add_task(report.user_deleted, username=removed_user, user_admin=next((u.admin for u in expired_users if u.username == removed_user), None), by=admin) + logger.info(f'User "{removed_user}" deleted') + bg.add_task( + report.user_deleted, + username=removed_user, + user_admin=next( + (u.admin for u in expired_users if u.username == removed_user), None + ), + by=admin, + ) return removed_users diff --git a/app/utils/responses.py b/app/utils/responses.py new file mode 100644 index 000000000..58669266c --- /dev/null +++ b/app/utils/responses.py @@ -0,0 +1,43 @@ +"""Documented Error responses for API routes""" + +from pydantic import BaseModel + + +class HTTPException(BaseModel): + detail: str + + +class Unauthorized(HTTPException): + detail: str = "Not authenticated" + + +class Forbidden(HTTPException): + detail: str = "You're not allowed 'to ...'" + + +class NotFound(HTTPException): + detail: str = "'Entity' '{}' not found" + + +class Conflict(HTTPException): + detail: str = "'Entity' already exists" + + +_400 = {"description": "Bad request", "model": HTTPException} + +_401 = { + "description": "Unauthorized", + "model": Unauthorized, + "headers": { + "WWW-Authenticate": { + "description": "Authentication type", + "type": "string", + }, + }, +} + +_403 = {"description": "Forbidden", "model": Forbidden} + +_404 = {"description": "Not found", "model": NotFound} + +_409 = {"description": "Conflict", "model": Conflict} diff --git a/requirements.txt b/requirements.txt index f8cfc79f6..e0f63816f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,6 @@ cryptography==39.0.1 Deprecated==1.2.13 ecdsa==0.18.0 fastapi==0.92.0 -fastapi-responses==0.2.1 greenlet==2.0.1 grpcio==1.50.0 grpcio-tools==1.44.0