diff --git a/app/db/crud.py b/app/db/crud.py index 94b21cf..1878726 100644 --- a/app/db/crud.py +++ b/app/db/crud.py @@ -309,7 +309,7 @@ def create_user(db: Session, user: UserCreate, admin: Admin = None): # proxies=proxies, key=user.key, services=db.query(Service) - .filter(Service.id.in_(user.services)) + .filter(Service.id.in_(user.service_ids)) .all(), # user.services, status=user.status, data_limit=(user.data_limit or None), @@ -383,9 +383,9 @@ def update_user(db: Session, dbuser: User, modify: UserModify): if modify.on_hold_expire_duration is not None: dbuser.on_hold_expire_duration = modify.on_hold_expire_duration - if modify.services is not None: + if modify.service_ids is not None: dbuser.services = ( - db.query(Service).filter(Service.id.in_(modify.services)).all() + db.query(Service).filter(Service.id.in_(modify.service_ids)).all() ) dbuser.edit_at = datetime.utcnow() @@ -547,8 +547,8 @@ def get_admins( def create_service(db: Session, service: ServiceCreate) -> Service: dbservice = Service( name=service.name, - inbounds=db.query(Inbound).filter(Inbound.id.in_(service.inbounds)).all(), - users=db.query(User).filter(User.id.in_(service.users)).all(), + inbounds=db.query(Inbound).filter(Inbound.id.in_(service.inbound_ids)).all(), + users=[], ) db.add(dbservice) db.commit() @@ -568,9 +568,9 @@ def update_service(db: Session, db_service: Service, modification: ServiceModify if modification.name is not None: db_service.name = modification.name - if modification.inbounds is not None: + if modification.inbound_ids is not None: db_service.inbounds = ( - db.query(Inbound).filter(Inbound.id.in_(modification.inbounds)).all() + db.query(Inbound).filter(Inbound.id.in_(modification.inbound_ids)).all() ) db.commit() diff --git a/app/db/models.py b/app/db/models.py index f322f58..255d5b8 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -61,12 +61,19 @@ class Service(Base): __tablename__ = "services" id = Column(Integer, primary_key=True) name = Column(String(64)) - # modifications = Column(String(1024)) users = relationship("User", secondary=users_services, back_populates="services") inbounds = relationship( "Inbound", secondary=inbounds_services, back_populates="services" ) + @property + def inbound_ids(self): + return [inbound.id for inbound in self.inbounds] + + @property + def user_ids(self): + return [user.id for user in self.users] + class User(Base): __tablename__ = "users" @@ -86,7 +93,6 @@ class User(Base): ".join(Inbound, Inbound.id == inbounds_services.c.inbound_id)", viewonly=True, distinct_target_key=True, - lazy="joined", ) # proxies = relationship("Proxy", back_populates="user", cascade="all, delete-orphan") status = Column(Enum(UserStatus), nullable=False, default=UserStatus.active) @@ -99,7 +105,6 @@ class User(Base): "NodeUserUsage", back_populates="user", cascade="all,delete,delete-orphan", - lazy="joined", ) notification_reminders = relationship( "NotificationReminder", @@ -126,6 +131,10 @@ class User(Base): on_hold_timeout = Column(DateTime) edit_at = Column(DateTime) + @property + def service_ids(self): + return [service.id for service in self.services] + class Inbound(Base): __tablename__ = "inbounds" @@ -144,6 +153,10 @@ class Inbound(Base): "InboundHost", back_populates="inbound", cascade="all, delete, delete-orphan" ) + @property + def service_ids(self): + return [service.id for service in self.services] + class InboundHost(Base): __tablename__ = "hosts" @@ -235,6 +248,10 @@ class Node(Base): Float, nullable=False, server_default=text("1.0"), default=1 ) + @property + def inbound_ids(self): + return [inbound.id for inbound in self.inbounds] + class NodeUserUsage(Base): __tablename__ = "node_user_usages" diff --git a/app/models/node.py b/app/models/node.py index 4928d92..b10804d 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import List, Optional from pydantic import ConfigDict, BaseModel, Field @@ -20,8 +19,8 @@ class NodeSettings(BaseModel): certificate: str -class NodeBase(BaseModel): - id: Optional[int] = Field(None) +class Node(BaseModel): + id: int | None = Field(None) name: str address: str port: int = 53042 @@ -32,20 +31,13 @@ class NodeBase(BaseModel): model_config = ConfigDict(from_attributes=True) -from app.models.proxy import InboundBase - - -class Node(NodeBase): - inbounds: Optional[List[InboundBase]] = None - - class NodeCreate(Node): model_config = ConfigDict( json_schema_extra={ "example": { "name": "DE node", "address": "192.168.1.1", - "port": 62050, + "port": 53042, "usage_coefficient": 1, } } @@ -53,19 +45,18 @@ class NodeCreate(Node): class NodeModify(Node): - name: Optional[str] = Field(None) - address: Optional[str] = Field(None) - port: Optional[int] = Field(None) - connection_backend: Optional[NodeConnectionBackend] = Field(None) - status: Optional[NodeStatus] = Field(None) - usage_coefficient: Optional[float] = Field(None, ge=0) + name: str | None = Field(None) + address: str | None = Field(None) + port: int | None = Field(None) + connection_backend: NodeConnectionBackend | None = Field(None) + status: NodeStatus | None = Field(None) + usage_coefficient: float | None = Field(None, ge=0) model_config = ConfigDict( json_schema_extra={ "example": { "name": "DE node", "address": "192.168.1.1", - "port": 62050, - "api_port": 62051, + "port": 53042, "status": "disabled", "usage_coefficient": 1.0, } @@ -74,18 +65,19 @@ class NodeModify(Node): class NodeResponse(Node): - xray_version: Optional[str] = None + xray_version: str | None = None status: NodeStatus - message: Optional[str] = None + message: str | None = None model_config = ConfigDict(from_attributes=True) + inbound_ids: list[int] | None = None class NodeUsageResponse(BaseModel): - node_id: Optional[int] = None + node_id: int | None = None node_name: str uplink: int downlink: int class NodesUsageResponse(BaseModel): - usages: List[NodeUsageResponse] + usages: list[NodeUsageResponse] diff --git a/app/models/proxy.py b/app/models/proxy.py index 08a8213..802a8a3 100644 --- a/app/models/proxy.py +++ b/app/models/proxy.py @@ -1,69 +1,17 @@ -import json from enum import Enum -from typing import Optional, List from pydantic import ConfigDict, BaseModel, Field, field_validator - -class XTLSFlows(Enum): - NONE = "" - VISION = "xtls-rprx-vision" - - -class ShadowsocksMethods(Enum): - AES_128_GCM = "aes-128-gcm" - AES_256_GCM = "aes-256-gcm" - CHACHA20_POLY1305 = "chacha20-ietf-poly1305" +from app.models.node import Node class ProxyTypes(str, Enum): # proxy_type = protocol - VMess = "vmess" VLESS = "vless" Trojan = "trojan" Shadowsocks = "shadowsocks" - @property - def settings_model(self): - if self == self.VMess: - return VMessSettings - if self == self.VLESS: - return VLESSSettings - if self == self.Trojan: - return TrojanSettings - if self == self.Shadowsocks: - return ShadowsocksSettings - - -class ProxySettings(BaseModel): - @classmethod - def from_dict(cls, proxy_type: ProxyTypes, _dict: dict): - return ProxyTypes(proxy_type).settings_model.parse_obj(_dict) - - def dict(self, *, no_obj=False, **kwargs): - if no_obj: - return json.loads(self.json()) - return super().dict(**kwargs) - - -class VMessSettings(ProxySettings): - id: str = Field(nullable=False) - - -class VLESSSettings(ProxySettings): - id: str = Field(nullable=False) - flow: XTLSFlows = XTLSFlows.NONE - - -class TrojanSettings(ProxySettings): - password: str = Field(nullable=False) - - -class ShadowsocksSettings(ProxySettings): - password: str = Field(nullable=False) - method: ShadowsocksMethods = ShadowsocksMethods.CHACHA20_POLY1305 - class InboundHostSecurity(str, Enum): inbound_default = "inbound_default" @@ -114,17 +62,17 @@ class FragmentSettings(BaseModel): class InboundHost(BaseModel): remark: str address: str - port: Optional[int] = Field(None) - sni: Optional[str] = Field(None) - host: Optional[str] = Field(None) - path: Optional[str] = Field(None) + port: int | None = Field(None) + sni: str | None = Field(None) + host: str | None = Field(None) + path: str | None = Field(None) security: InboundHostSecurity = InboundHostSecurity.inbound_default alpn: InboundHostALPN = InboundHostALPN.none fingerprint: InboundHostFingerprint = InboundHostFingerprint.none - allowinsecure: Optional[bool] = None - is_disabled: Optional[bool] = None + allowinsecure: bool | None = None + is_disabled: bool | None = None mux: bool = Field(False) - fragment: Optional[FragmentSettings] = Field(None) + fragment: FragmentSettings | None = Field(None) model_config = ConfigDict(from_attributes=True) @field_validator("remark", "address", "path") @@ -142,19 +90,11 @@ class InboundHostResponse(InboundHost): id: int -class InboundBase(BaseModel): - id: Optional[int] = Field(None) - tag: str = Field() - protocol: ProxyTypes = Field() +class Inbound(BaseModel): + id: int + tag: str + protocol: ProxyTypes config: str - node_id: Optional[int] = Field(None) + node: Node + service_ids: list[int] model_config = ConfigDict(from_attributes=True) - - -from app.models.service import ServiceBase -from app.models.node import NodeBase - - -class Inbound(InboundBase): - services: Optional[List[ServiceBase]] = None - node: Optional[NodeBase] = None diff --git a/app/models/service.py b/app/models/service.py index 9bae7f0..5a8f119 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -1,57 +1,35 @@ -from typing import List, Optional +from pydantic import ConfigDict, BaseModel, Field -from pydantic import ConfigDict, BaseModel, Field, computed_field - -class ServiceBase(BaseModel): - id: Optional[int] = None - name: Optional[str] = Field(None) +class Service(BaseModel): + id: int | None = None + name: str | None = Field(None) model_config = ConfigDict(from_attributes=True) -from app.models.proxy import InboundBase -from app.models.user import UserBase - - -class Service(ServiceBase): - users: List[UserBase] = Field([]) - inbounds: List[InboundBase] = Field([]) - - -class ServiceCreate(ServiceBase): - users: List[int] = Field([]) - inbounds: List[int] = Field([]) +class ServiceCreate(Service): + inbound_ids: list[int] = Field([]) model_config = ConfigDict( json_schema_extra={ "example": { "name": "my service 1", - "inbounds": [1, 5, 9], - "users": [1, 2, 3], + "inbound_ids": [1, 5, 9], } } ) -class ServiceModify(ServiceBase): - inbounds: Optional[List[int]] = Field(None) +class ServiceModify(Service): + inbound_ids: list[int] | None = Field(None) model_config = ConfigDict( json_schema_extra={ - "example": {"id": 2, "name": "my service 2", "inbounds": [1, 2, 3]} + "example": {"id": 2, "name": "my service 2", "inbound_ids": [1, 2, 3]} } ) class ServiceResponse(Service): - id: int - - @computed_field - @property - def user_ids(self) -> List[int]: - return [u.id for u in self.users] - - @computed_field - @property - def inbound_ids(self) -> List[int]: - return [i.id for i in self.inbounds] + inbound_ids: list[int] + user_ids: list[int] model_config = ConfigDict(from_attributes=True) diff --git a/app/models/user.py b/app/models/user.py index 3f6ef82..478d416 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,7 +1,7 @@ import secrets from datetime import datetime from enum import Enum -from typing import List, Optional, Union, Annotated, Literal +from typing import List, Union, Annotated, Literal from pydantic import ( field_validator, @@ -48,39 +48,31 @@ class UserDataLimitResetStrategy(str, Enum): year = "year" -class UserBase(BaseModel): - id: Optional[int] = None +class User(BaseModel): + id: int | None = None username: Annotated[str, StringConstraints(to_lower=True, pattern=USERNAME_REGEXP)] expire: Union[datetime, None, Literal[0]] = Field(None) key: str = Field(default_factory=lambda: secrets.token_hex(16)) - data_limit: Optional[int] = Field( + data_limit: int | None = Field( ge=0, default=None, description="data_limit can be 0 or greater" ) enabled: bool = Field(default=True) data_limit_reset_strategy: UserDataLimitResetStrategy = ( UserDataLimitResetStrategy.no_reset ) - note: Optional[Annotated[str, Field(max_length=500)]] = None - sub_updated_at: Optional[datetime] = Field(None) - sub_last_user_agent: Optional[str] = Field(None) - online_at: Optional[datetime] = Field(None) - on_hold_expire_duration: Optional[int] = Field(None) - on_hold_timeout: Optional[datetime] = Field(None) + note: Annotated[str, Field(max_length=500)] | None = None + sub_updated_at: datetime | None = Field(None) + sub_last_user_agent: str | None = Field(None) + online_at: datetime | None = Field(None) + on_hold_expire_duration: int | None = Field(None) + on_hold_timeout: datetime | None = Field(None) model_config = ConfigDict(from_attributes=True) -from app.models.service import ServiceBase -from app.models.proxy import InboundBase - - -class User(UserBase): - pass - - class UserCreate(User): status: UserStatusCreate = Field(UserStatusCreate.active) - services: List[int] = Field([]) + service_ids: list[int] = Field([]) model_config = ConfigDict( json_schema_extra={ "example": { @@ -125,9 +117,9 @@ class UserCreate(User): class UserModify(User): - status: Optional[UserStatusModify] = Field(None) - services: List[int] = Field([]) - data_limit_reset_strategy: Optional[UserDataLimitResetStrategy] = Field(None) + status: UserStatusModify | None = Field(None) + service_ids: list[int] | None = Field(None) + data_limit_reset_strategy: UserDataLimitResetStrategy | None = Field(None) model_config = ConfigDict( json_schema_extra={ "example": { @@ -170,18 +162,10 @@ class UserResponse(User): used_traffic: int lifetime_used_traffic: int = 0 created_at: datetime - inbounds: List[InboundBase] = Field([]) - services: List[ServiceBase] = Field([]) - links: List[str] | None = [] # Field(None) + service_ids: list[int] - # subscription_url: str = "" model_config = ConfigDict(from_attributes=True) - @computed_field - @property - def service_ids(self) -> List[int]: - return [s.id for s in self.services] - @computed_field @property def subscription_url(self) -> str: diff --git a/app/routes/service.py b/app/routes/service.py index 60f3dfe..7f977a8 100644 --- a/app/routes/service.py +++ b/app/routes/service.py @@ -6,9 +6,11 @@ from fastapi_pagination.links import Page from app import marznode from app.db import crud -from app.db.models import Service +from app.db.models import Service, User from app.dependencies import DBDep, sudo_admin +from app.models.proxy import Inbound from app.models.service import ServiceCreate, ServiceModify, ServiceResponse +from app.models.user import UserResponse router = APIRouter( prefix="/services", dependencies=[Depends(sudo_admin)], tags=["Service"] @@ -52,6 +54,36 @@ def get_service(id: int, db: DBDep): return dbservice +@router.get("/{id}/users", response_model=Page[UserResponse]) +def get_service_users(id: int, db: DBDep): + """ + Get service users + """ + service = crud.get_service(db, id) + + if not service: + raise HTTPException(status_code=404, detail="Service not found") + + query = db.query(User).join(User.services).where(Service.id == service.id) + + return paginate(query) + + +@router.get("/{id}/inbounds", response_model=Page[Inbound]) +def get_service_inbounds(id: int, db: DBDep): + """ + Get service inbounds + """ + service = crud.get_service(db, id) + + if not service: + raise HTTPException(status_code=404, detail="Service not found") + + query = db.query(Inbound).join(Inbound.services).where(Service.id == service.id) + + return paginate(query) + + @router.put("/{id}", response_model=ServiceResponse) async def modify_service(id: int, modification: ServiceModify, db: DBDep): """ diff --git a/app/routes/subscription.py b/app/routes/subscription.py index bf08877..75e5ea6 100644 --- a/app/routes/subscription.py +++ b/app/routes/subscription.py @@ -43,7 +43,7 @@ def user_subscription( user: UserResponse = UserResponse.model_validate(db_user) if "text/html" in accept_header: - links = generate_subscription(user=user, config_format="v2ray").split() + links = generate_subscription(user=db_user, config_format="v2ray").split() return HTMLResponse( render_template(SUBSCRIPTION_PAGE_TEMPLATE, {"user": user, "links": links}) ) @@ -64,18 +64,20 @@ def user_subscription( crud.update_user_sub(db, db_user, user_agent) if re.match("^([Cc]lash-verge|[Cc]lash-?[Mm]eta)", user_agent): - conf = generate_subscription(user=user, config_format="clash-meta") + conf = generate_subscription(user=db_user, config_format="clash-meta") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif re.match("^([Cc]lash|[Ss]tash)", user_agent): - conf = generate_subscription(user=user, config_format="clash") + conf = generate_subscription(user=db_user, config_format="clash") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif re.match("^(SFA|SFI|SFM|SFT)", user_agent): - conf = generate_subscription(user=user, config_format="sing-box") + conf = generate_subscription(user=db_user, config_format="sing-box") return Response( content=conf, media_type="application/json", headers=response_headers ) else: - conf = generate_subscription(user=user, config_format="v2ray", as_base64=True) + conf = generate_subscription( + user=db_user, config_format="v2ray", as_base64=True + ) return Response(content=conf, media_type="text/plain", headers=response_headers) @@ -119,24 +121,26 @@ def user_subscription_with_client_type( } if client_type == "clash-meta": - conf = generate_subscription(user=user, config_format="clash-meta") + conf = generate_subscription(user=db_user, config_format="clash-meta") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif client_type == "sing-box": - conf = generate_subscription(user=user, config_format="sing-box") + conf = generate_subscription(user=db_user, config_format="sing-box") return Response( content=conf, media_type="application/json", headers=response_headers ) elif client_type == "clash": - conf = generate_subscription(user=user, config_format="clash") + conf = generate_subscription(user=db_user, config_format="clash") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif client_type == "v2ray": - conf = generate_subscription(user=user, config_format="v2ray", as_base64=True) + conf = generate_subscription( + user=db_user, config_format="v2ray", as_base64=True + ) return Response(content=conf, media_type="text/plain", headers=response_headers) elif client_type == "xray": return Response( - content=generate_subscription(user=user, config_format="xray"), + content=generate_subscription(user=db_user, config_format="xray"), headers=response_headers, ) else: diff --git a/app/routes/user.py b/app/routes/user.py index c6894ba..b160b8c 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -11,6 +11,7 @@ from fastapi_pagination.links import Page from app import marznode from app.db import crud, User +from app.db.models import Service from app.dependencies import ( DBDep, AdminDep, @@ -19,6 +20,7 @@ from app.dependencies import ( StartDateDep, EndDateDep, ) +from app.models.service import ServiceResponse from app.models.user import ( UserCreate, UserModify, @@ -89,7 +91,6 @@ async def add_user(new_user: UserCreate, db: DBDep, admin: AdminDep): - **data_limit** must be in Bytes, e.g. 1073741824B = 1GB - **services** list of service ids """ - # TODO expire should be datetime instead of timestamp try: db_user = crud.create_user( @@ -225,6 +226,21 @@ async def remove_user(db_user: UserDep, db: DBDep, admin: AdminDep): return {} +@router.get("/{username}/services", response_model=Page[ServiceResponse]) +def get_user_services(username: str, db: DBDep): + """ + Get user services + """ + user = crud.get_user(db, username) + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + query = db.query(Service).join(Service.users).where(User.username == username) + + return paginate(query) + + @router.post("/{username}/reset", response_model=UserResponse) async def reset_user_data_usage(db_user: UserDep, db: DBDep, admin: AdminDep): """ diff --git a/app/utils/share.py b/app/utils/share.py index af8fc9a..f1e2e61 100644 --- a/app/utils/share.py +++ b/app/utils/share.py @@ -5,7 +5,7 @@ import secrets from collections import defaultdict from datetime import datetime as dt, timedelta from importlib import resources -from typing import TYPE_CHECKING, Literal, Union, List +from typing import Literal, Union, List from uuid import UUID from jdatetime import date as jd @@ -16,6 +16,7 @@ from app.db import GetDB, crud from app.models.proxy import ( InboundHostSecurity, ) +from app.models.user import UserResponse from app.models.user import UserStatus from app.utils.keygen import gen_uuid, gen_password from app.utils.system import get_public_ip, readable_size @@ -25,9 +26,6 @@ from config import ( CLASH_SUBSCRIPTION_TEMPLATE, ) -if TYPE_CHECKING: - from app.models.user import UserResponse - SERVER_IP = get_public_ip() STATUS_EMOJIS = { @@ -65,7 +63,9 @@ def generate_subscription( inbounds = user.inbounds key = user.key - extra_data = user.model_dump(exclude={"subscription_url", "services", "inbounds"}) + extra_data = UserResponse.model_validate(user).model_dump( + exclude={"subscription_url", "services", "inbounds"} + ) format_variables = setup_format_variables(extra_data) if config_format not in subscription_handlers.keys(): diff --git a/dashboard/src/components/entity-field-card/index.tsx b/dashboard/src/components/entity-field-card/index.tsx new file mode 100644 index 0000000..f6755a2 --- /dev/null +++ b/dashboard/src/components/entity-field-card/index.tsx @@ -0,0 +1,34 @@ +import { LucideIcon } from 'lucide-react'; +import { Label, Separator } from '@marzneshin/components'; +import { FC } from 'react' + +interface EntityFieldCardProps { + FirstIcon: LucideIcon; + SecondIcon: LucideIcon; + firstAmount: number | string; + secondAmount: number | string; + name: string; +} + + +export const EntityCardProperty = ({ Icon, amount }: { Icon: LucideIcon, amount: number | string }) => ( +