diff --git a/app/marzneshin.py b/app/marzneshin.py index 2e823f5..4254472 100644 --- a/app/marzneshin.py +++ b/app/marzneshin.py @@ -1,7 +1,5 @@ import asyncio -import atexit import logging -import os from datetime import datetime as dt from datetime import timedelta as td @@ -11,6 +9,7 @@ from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse +from fastapi_pagination import add_pagination from starlette.staticfiles import StaticFiles from uvicorn import Config, Server @@ -19,7 +18,6 @@ from app.templates import render_template from config import (DEBUG, DOCS, HOME_PAGE_TEMPLATE, UVICORN_HOST, UVICORN_PORT, UVICORN_SSL_CERTFILE, UVICORN_SSL_KEYFILE, UVICORN_UDS) - from . import __version__, telegram from .routes import api_router from .tasks import (delete_expired_reminders, nodes_startup, @@ -38,6 +36,7 @@ app = FastAPI( ) app.include_router(api_router) +add_pagination(app) @app.get("/", response_class=HTMLResponse) diff --git a/app/models/node.py b/app/models/node.py index e232b43..ced12c6 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -8,7 +8,6 @@ class NodeStatus(str, Enum): healthy = "healthy" unhealthy = "unhealthy" disabled = "disabled" - none = "none" class NodeConnectionBackend(str, Enum): diff --git a/app/models/user.py b/app/models/user.py index 4d44f16..a34bf40 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -196,11 +196,6 @@ class UserResponse(User): salt = secrets.token_hex(8) url_prefix = (XRAY_SUBSCRIPTION_URL_PREFIX).replace('*', salt) return f"{url_prefix}/sub/{self.username}/{self.key}" - - -class UsersResponse(BaseModel): - total: int - users: List[UserResponse] class UserUsageResponse(BaseModel): diff --git a/app/routes/admin.py b/app/routes/admin.py index d63ccf0..b9b38d0 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,11 +1,14 @@ -from typing import List, Optional, Annotated +from typing import Optional, Annotated import sqlalchemy from fastapi import APIRouter from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm +from fastapi_pagination import Page +from fastapi_pagination.ext.sqlalchemy import paginate from app.db import Session, crud +from app.db.models import Admin as DBAdmin from app.dependencies import AdminDep, SudoAdminDep, DBDep from app.models.admin import Admin, AdminCreate, AdminInDB, AdminModify, Token from app.utils.jwt import create_admin_token @@ -29,6 +32,34 @@ def authenticate_admin(db: Session, username: str, password: str) -> Optional[Ad return dbadmin if AdminInDB.model_validate(dbadmin).verify_password(password) else None +@router.get("", response_model=Page[Admin]) +def get_admins(db: DBDep, + admin: SudoAdminDep, + username: str = None): + query = db.query(DBAdmin) + if username: + query = query.filter(DBAdmin.username.ilike(f'%{username}%')) + return paginate(db, query) + + +@router.post("", response_model=Admin) +def create_admin(new_admin: AdminCreate, + db: DBDep, + admin: SudoAdminDep): + try: + dbadmin = crud.create_admin(db, new_admin) + except sqlalchemy.exc.IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="Admin already exists") + + return dbadmin + + +@router.get("/current", response_model=Admin) +def get_current_admin(admin: AdminDep): + return admin + + @router.post("/token", response_model=Token) def admin_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: DBDep): @@ -45,19 +76,6 @@ def admin_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -@router.post("", response_model=Admin) -def create_admin(new_admin: AdminCreate, - db: DBDep, - admin: SudoAdminDep): - try: - dbadmin = crud.create_admin(db, new_admin) - except sqlalchemy.exc.IntegrityError: - db.rollback() - raise HTTPException(status_code=409, detail="Admin already exists") - - return dbadmin - - @router.put("/{username}", response_model=Admin) def modify_admin(username: str, modified_admin: AdminModify, @@ -101,17 +119,3 @@ def remove_admin(username: str, crud.remove_admin(db, dbadmin) return {} - - -@router.get("/current", response_model=Admin) -def get_current_admin(admin: AdminDep): - return admin - - -@router.get("", response_model=List[Admin]) -def get_admins(db: DBDep, - admin: SudoAdminDep, - offset: int = None, - limit: int = None, - username: str = None): - return crud.get_admins(db, offset, limit, username) diff --git a/app/routes/inbounds.py b/app/routes/inbounds.py index 9fbb059..853f941 100644 --- a/app/routes/inbounds.py +++ b/app/routes/inbounds.py @@ -1,27 +1,31 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from fastapi import HTTPException +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links import Page from app.db import crud +from app.db.models import InboundHost as DBInboundHost, Inbound as DBInbound from app.dependencies import DBDep, sudo_admin from app.models.proxy import Inbound, InboundHost, InboundHostResponse -from app.db.models import InboundHost as DBInboundHost router = APIRouter(prefix="/inbounds", dependencies=[Depends(sudo_admin)], tags=['Inbounds']) -@router.get("", response_model=list[Inbound]) -def get_inbounds(db: DBDep): +@router.get("", response_model=Page[Inbound]) +def get_inbounds(db: DBDep, tag: str = Query(None)): """ Get all inbounds """ - inbounds = crud.get_all_inbounds(db) + query = db.query(DBInbound) + if tag: + query = query.filter(DBInbound.tag.ilike(f"%{tag}%")) - return inbounds + return paginate(db, query) -@router.get('/hosts', response_model=list[InboundHost]) +@router.get('/hosts', response_model=Page[InboundHost]) def get_hosts(db: DBDep): - return db.query(DBInboundHost).all() + return paginate(db.query(DBInboundHost)) @router.put("/hosts/{id}", response_model=InboundHostResponse) @@ -63,19 +67,16 @@ def get_inbound(id: int, db: DBDep): return inbound -@router.get("/{id}/hosts", response_model=list[InboundHostResponse]) +@router.get("/{id}/hosts", response_model=Page[InboundHostResponse]) def get_inbound_hosts(id: int | None, db: DBDep): """ Get hosts of a specific inbound """ - if not id: - print() - return inbound = crud.get_inbound(db, id) if not inbound: raise HTTPException(status_code=404, detail="Inbound not found") - return inbound.hosts + return paginate(db, db.query(DBInboundHost).filter(DBInboundHost.inbound_id == id)) @router.post("/{id}/hosts", response_model=InboundHostResponse) diff --git a/app/routes/node.py b/app/routes/node.py index 6c43d50..2ac7d2e 100644 --- a/app/routes/node.py +++ b/app/routes/node.py @@ -1,19 +1,18 @@ import json import logging -from datetime import datetime -from typing import List, Annotated +from typing import Annotated import sqlalchemy -from fastapi import APIRouter, Depends, Body +from fastapi import APIRouter, Body, Query from fastapi import HTTPException, WebSocket -from starlette.requests import Request -from starlette.responses import PlainTextResponse +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links import Page from app import marznode from app.db import crud, get_tls_certificate -from app.dependencies import DBDep, SudoAdminDep, sudo_admin, EndDateDep, StartDateDep, get_admin -from app.marznode import MarzNodeGRPCIO -from app.models.admin import Admin +from app.db.models import Node +from app.dependencies import DBDep, SudoAdminDep, EndDateDep, StartDateDep, get_admin + from app.models.node import (NodeCreate, NodeModify, NodeResponse, NodeSettings, NodeStatus, NodesUsageResponse) @@ -21,6 +20,39 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/nodes", tags=['Node']) +@router.get("", response_model=Page[NodeResponse]) +def get_nodes(db: DBDep, + admin: SudoAdminDep, + status: list[NodeStatus] = Query(None), + name: str = Query(None)): + query = db.query(Node) + + if name: + query = query.filter(Node.name.ilike(f"%{name}%")) + + if status: + query = query.filter(Node.status.in_(status)) + + return paginate(db, query) + + +@router.post("", response_model=NodeResponse) +async def add_node(new_node: NodeCreate, + db: DBDep, + admin: SudoAdminDep): + try: + db_node = crud.create_node(db, new_node) + except sqlalchemy.exc.IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail=f"Node \"{new_node.name}\" already exists") + certificate = get_tls_certificate(db) + + await marznode.operations.add_node(db_node, certificate) + + logger.info("New node `%s` added", db_node.name) + return db_node + + @router.get("/settings", response_model=NodeSettings) def get_node_settings(db: DBDep, admin: SudoAdminDep): @@ -44,23 +76,6 @@ def get_usage(db: DBDep, return {"usages": usages} -@router.post("", response_model=NodeResponse) -async def add_node(new_node: NodeCreate, - db: DBDep, - admin: SudoAdminDep): - try: - db_node = crud.create_node(db, new_node) - except sqlalchemy.exc.IntegrityError: - db.rollback() - raise HTTPException(status_code=409, detail=f"Node \"{new_node.name}\" already exists") - certificate = get_tls_certificate(db) - - await marznode.operations.add_node(db_node, certificate) - - logger.info("New node `%s` added", db_node.name) - return db_node - - @router.get("/{node_id}", response_model=NodeResponse) def get_node(node_id: int, db: DBDep, @@ -98,12 +113,6 @@ async def node_logs(node_id: int, await websocket.send_text(line) -@router.get("", response_model=List[NodeResponse]) -def get_nodes(db: DBDep, - admin: SudoAdminDep): - return crud.get_nodes(db) - - @router.put("/{node_id}", response_model=NodeResponse) async def modify_node(node_id: int, modified_node: NodeModify, @@ -124,6 +133,21 @@ async def modify_node(node_id: int, return db_node +@router.delete("/{node_id}") +async def remove_node(node_id: int, + db: DBDep, + admin: SudoAdminDep): + db_node = crud.get_node_by_id(db, node_id) + if not db_node: + raise HTTPException(status_code=404, detail="Node not found") + + crud.remove_node(db, db_node) + await marznode.operations.remove_node(db_node.id) + + logger.info(f"Node `%s` deleted", db_node.name) + return {} + + @router.post("/{node_id}/resync") async def reconnect_node(node_id: int, db: DBDep, @@ -154,18 +178,3 @@ async def alter_node_xray_config(node_id: int, xray_config = json.dumps(body) await node.restart_xray(xray_config) return {} - - -@router.delete("/{node_id}") -async def remove_node(node_id: int, - db: DBDep, - admin: SudoAdminDep): - db_node = crud.get_node_by_id(db, node_id) - if not db_node: - raise HTTPException(status_code=404, detail="Node not found") - - crud.remove_node(db, db_node) - await marznode.operations.remove_node(db_node.id) - - logger.info(f"Node `%s` deleted", db_node.name) - return {} diff --git a/app/routes/service.py b/app/routes/service.py index b3dd628..840c5b5 100644 --- a/app/routes/service.py +++ b/app/routes/service.py @@ -1,11 +1,13 @@ -from typing import List - import sqlalchemy -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from fastapi import HTTPException +from fastapi_pagination.customization import UseParamsFields, CustomizedPage +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links import Page from app import marznode from app.db import crud +from app.db.models import Service from app.dependencies import DBDep, sudo_admin from app.models.service import (ServiceCreate, ServiceModify, ServiceResponse) @@ -13,6 +15,14 @@ from app.models.service import (ServiceCreate, ServiceModify, router = APIRouter(prefix="/services", dependencies=[Depends(sudo_admin)], tags=['Service']) +@router.get("", response_model=Page[ServiceResponse]) +def get_services(db: DBDep, name: str = Query(None)): + query = db.query(Service) + if name: + query = query.filter(Service.name.ilike(f"%{name}%")) + return paginate(query) + + @router.post("", response_model=ServiceResponse) def add_service(new_service: ServiceCreate, db: DBDep): @@ -77,10 +87,3 @@ def remove_service(id: int, crud.remove_service(db, dbservice) return dict() - - -@router.get("", response_model=List[ServiceResponse]) -def get_services(db: DBDep, - offset: int = None, - limit: int = None): - return crud.get_services(db) # , offset, limit) diff --git a/app/routes/system.py b/app/routes/system.py index 034b1d0..d86d23a 100644 --- a/app/routes/system.py +++ b/app/routes/system.py @@ -1,13 +1,11 @@ -from typing import Dict, List, Union +from typing import Union from fastapi import APIRouter from app import __version__ -# from .. import __version__ from app.db import crud -from app.dependencies import DBDep, AdminDep, SudoAdminDep +from app.dependencies import DBDep, AdminDep from app.models.admin import Admin -from app.models.proxy import InboundHost, Inbound from app.models.system import SystemStats from app.models.user import UserStatus from app.utils.system import memory_usage, cpu_usage, realtime_bandwidth @@ -39,31 +37,3 @@ def get_system_stats(db: DBDep, admin: AdminDep): incoming_bandwidth_speed=realtime_bandwidth_stats.incoming_bytes, outgoing_bandwidth_speed=realtime_bandwidth_stats.outgoing_bytes, ) - - -@router.get("/hosts", response_model=Dict[int, List[InboundHost]]) -def get_hosts(db: DBDep, admin: SudoAdminDep): - hosts = crud.get_all_hosts(db) - - return hosts - - -@router.put('/api/hosts', response_model=Dict[str, List[InboundHost]]) -def modify_hosts(modified_hosts: Dict[str, List[InboundHost]], - db: DBDep, - admin: SudoAdminDep): - # validate - # for inbound_id, hosts in modified_hosts.items(): - # if not xray.config.inbounds_by_id.get(inbound_id): - # raise HTTPException(status_code=400, detail=f"Inbound {inbound_id} doesn't exist") - - for inbound_id, hosts in modified_hosts.items(): - crud.update_hosts(db, inbound_id, hosts) - - # xray.hosts.update() - - hosts = {} - # for inbound_tag in xray.config.inbounds_by_tag: - # hosts[inbound_tag] = crud.get_hosts(db, inbound_tag) - - return hosts diff --git a/app/routes/user.py b/app/routes/user.py index 2d64498..73a17d3 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -1,23 +1,70 @@ import asyncio import logging from datetime import datetime, timezone -from typing import List +from enum import Enum import sqlalchemy from fastapi import APIRouter from fastapi import HTTPException, Query +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links import Page from app import marznode -from app.db import crud +from app.db import crud, User from app.dependencies import DBDep, AdminDep, SudoAdminDep, UserDep, StartDateDep, EndDateDep from app.models.user import (UserCreate, UserModify, UserResponse, - UsersResponse, UserStatus, UserUsagesResponse) + UserStatus, UserUsagesResponse) from app.utils import report logger = logging.getLogger(__name__) router = APIRouter(prefix="/users", tags=['User']) +class UsersSortingOptions(str, Enum): + username = "username" + used_traffic = "used_traffic" + data_limit = "data_limit" + expire = "expire" + created_at = "created_at" + + +@router.get("", response_model=Page[UserResponse]) +def get_users(db: DBDep, + admin: AdminDep, + username: list[str] = Query(None), + status: list[UserStatus] = Query(None), + order_by: UsersSortingOptions = Query(None), + descending: bool = Query(False)): + """ + Filters users based on the options + """ + dbadmin = crud.get_admin(db, admin.username) + + query = db.query(User) + + admin = dbadmin if not admin.is_sudo else None + + if username: + if len(username) > 1: + query = query.filter(User.username.in_(username)) + else: + query = query.filter(User.username.ilike(f"%{username[0]}%")) + + if status: + query = query.filter(User.status.in_(status)) + + if admin: + query = query.filter(User.admin == admin) + + if order_by: + order_column = getattr(User, order_by) + if descending: + order_column = order_column.desc() + query = query.order_by(order_column) + + return paginate(db, query) + + @router.post("", response_model=UserResponse) async def add_user(new_user: UserCreate, db: DBDep, @@ -51,6 +98,55 @@ async def add_user(new_user: UserCreate, return user +@router.post("/reset") +async def reset_users_data_usage(db: DBDep, + admin: SudoAdminDep): + """ + Reset all users data usage, + You will need to restart for this to take effect for now + """ + dbadmin = crud.get_admin(db, admin.username) + crud.reset_all_users_data_usage(db=db, admin=dbadmin) + return {} + + +@router.delete("/expired") +def delete_expired(passed_time: int, + db: DBDep, + admin: AdminDep): + """ + Delete expired users + - **passed_time** must be a timestamp + - This function will delete all expired users that meet the specified number of days passed and can't be undone. + """ + + dbadmin = crud.get_admin(db, admin.username) + + db_users = crud.get_users(db=db, + status=[UserStatus.expired, UserStatus.limited], + admin=dbadmin if not admin.is_sudo else None) + + current_time = int(datetime.now(timezone.utc).timestamp()) + expiration_threshold = current_time - passed_time + expired_users = [ + user for user in db_users if user.expire is not None and user.expire <= expiration_threshold] + if not expired_users: + raise HTTPException(status_code=404, detail="No expired user found.") + + for db_user in expired_users: + crud.remove_user(db, db_user) + + asyncio.create_task( + report.user_deleted( + username=db_user.username, + by=admin + )) + + logger.info("User `%s` removed", db_user.username) + + return {} + + @router.get("/{username}", response_model=UserResponse) def get_user(db_user: UserDep): """ @@ -174,53 +270,6 @@ async def revoke_user_subscription(db_user: UserDep, return user -@router.get("", response_model=UsersResponse) -def get_users(db: DBDep, - admin: AdminDep, - offset: int = None, - limit: int = None, - username: List[str] = Query(None), - status: UserStatus = None, - sort: str = None): - """ - Get all users - """ - dbadmin = crud.get_admin(db, admin.username) - - if sort is not None: - 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, - usernames=username, - status=status, - sort=sort, - admin=dbadmin if not admin.is_sudo else None, - return_with_count=True) - - return {"users": users, "total": count} - - -@router.post("/reset") -async def reset_users_data_usage(db: DBDep, - admin: SudoAdminDep): - """ - Reset all users data usage, - You will need to restart for this to take effect for now - """ - dbadmin = crud.get_admin(db, admin.username) - crud.reset_all_users_data_usage(db=db, admin=dbadmin) - return {} - - @router.get("/{username}/usage", response_model=UserUsagesResponse) def get_user_usage(db: DBDep, db_user: UserDep, @@ -253,40 +302,3 @@ def set_owner(username: str, logger.info("`%s`'s owner successfully set to `%s`", user.username, admin.username) return user - - -@router.delete("/expired") -def delete_expired(passed_time: int, - db: DBDep, - admin: AdminDep): - """ - Delete expired users - - **passed_time** must be a timestamp - - This function will delete all expired users that meet the specified number of days passed and can't be undone. - """ - - dbadmin = crud.get_admin(db, admin.username) - - db_users = crud.get_users(db=db, - status=[UserStatus.expired, UserStatus.limited], - admin=dbadmin if not admin.is_sudo else None) - - current_time = int(datetime.now(timezone.utc).timestamp()) - expiration_threshold = current_time - passed_time - expired_users = [ - user for user in db_users if user.expire is not None and user.expire <= expiration_threshold] - if not expired_users: - raise HTTPException(status_code=404, detail="No expired user found.") - - for db_user in expired_users: - crud.remove_user(db, db_user) - - asyncio.get_running_loop().create_task( - report.user_deleted( - username=db_user.username, - by=admin - )) - - logger.info("User `%s` removed", db_user.username) - - return {}