diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index d13a6df..5868279 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -1,23 +1,37 @@ -name: Pylint - -on: [push] +name: Lint +on: + push: + branches: [ "master" ] + paths: + - 'app/**' + pull_request: + branches: [ "master" ] + paths: + - 'app/**' jobs: - build: + lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11.2 - uses: actions/setup-python@v3 - with: - python-version: 3.11.2 + - uses: actions/checkout@v3 + + - name: black + uses: psf/black@stable + with: + options: "--check --verbose" + src: "./app" + + - name: Set up Python 3.11.2 + uses: actions/setup-python@v3 + with: + python-version: 3.11.2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint - pip install -r requirements.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + pip install -r requirements.txt - - name: Lint with pylint - run: | + - name: Lint with pylint + run: | pylint --exit-zero "$(git ls-files '*.py')" diff --git a/.github/workflows/api-codeql.yml b/.github/workflows/api-codeql.yml index da19181..a20c6a5 100644 --- a/.github/workflows/api-codeql.yml +++ b/.github/workflows/api-codeql.yml @@ -1,13 +1,14 @@ - name: "API CodeQL" on: push: branches: [ "master" ] + paths-ignore: + - "dashboard/**" pull_request: branches: [ "master" ] paths-ignore: - - 'app/dashboard/**' + - 'dashboard/**' schedule: - cron: '40 13 * * 2' @@ -35,19 +36,19 @@ jobs: language: [ 'python' ] steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/chromatic-deploy.yml b/.github/workflows/chromatic-deploy.yml index 71f238f..2d9bd18 100644 --- a/.github/workflows/chromatic-deploy.yml +++ b/.github/workflows/chromatic-deploy.yml @@ -3,7 +3,7 @@ name: Chromatic Deploy on: push: branches: - - main + - master pull_request: concurrency: @@ -34,7 +34,7 @@ jobs: dashboard/** chromatic: name: Visual tests - needs: [changed-files] + needs: [ changed-files ] if: ${{ needs.changed-files.outputs.any_changed == 'true' }} uses: ./.github/workflows/chromatic.yml secrets: inherit diff --git a/.github/workflows/dashboard-ci.yml b/.github/workflows/dashboard-ci.yml index 18cbcd8..1ef2dab 100644 --- a/.github/workflows/dashboard-ci.yml +++ b/.github/workflows/dashboard-ci.yml @@ -3,11 +3,16 @@ name: Dashboard CI on: push: branches: [ "master" ] + paths: + - "dashboard/**" pull_request: + branches: [ "master" ] + paths: + - "dashboard/**" jobs: lint: - env: + env: VITE_BASE_API: /api/ runs-on: ubuntu-latest defaults: @@ -41,8 +46,8 @@ jobs: run: pnpm lint build: - needs: [lint] - env: + needs: [ lint ] + env: VITE_BASE_API: /api/ runs-on: ubuntu-latest defaults: @@ -74,7 +79,7 @@ jobs: - name: Build Dashboard run: pnpm build --base '/dashboard/' - + - name: Upload a Build Artifact uses: actions/upload-artifact@v4.2.0 with: @@ -82,8 +87,8 @@ jobs: path: dashboard/dist unit-test: - needs: [lint, build] - env: + needs: [ lint, build ] + env: VITE_BASE_API: /api/ runs-on: ubuntu-latest defaults: diff --git a/.github/workflows/dashboard-codeql.yml b/.github/workflows/dashboard-codeql.yml index 4671d6f..9fa7c7f 100644 --- a/.github/workflows/dashboard-codeql.yml +++ b/.github/workflows/dashboard-codeql.yml @@ -14,11 +14,11 @@ name: "Dashboard CodeQL" on: push: branches: [ "master" ] - paths: + paths: - 'dashboard/**' pull_request: branches: [ "master" ] - paths: + paths: - 'dashboard/**' schedule: - cron: '40 13 * * 2' @@ -47,19 +47,19 @@ jobs: language: [ 'javascript-typescript' ] steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} - - name: Autobuild - uses: github/codeql-action/autobuild@v3 + - name: Autobuild + uses: github/codeql-action/autobuild@v3 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 36ec3d7..18678e5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -33,19 +33,19 @@ on: jobs: build-docker-image: runs-on: ubuntu-latest - env: + env: VITE_BASE_API: /api/ steps: - name: Checkout uses: actions/checkout@v4 - + - uses: pnpm/action-setup@v3 name: Install pnpm with: version: 8 run_install: false - - - name: Use Node.js + + - name: Use Node.js uses: actions/setup-node@v4 with: node-version: '20.x' @@ -53,17 +53,17 @@ jobs: - name: Install dependencies run: pnpm install working-directory: ./dashboard/ - + - name: Build Dashboard run: npm run build -- --outDir dist --base '/dashboard/' working-directory: ./dashboard/ - + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -80,7 +80,7 @@ jobs: tags: | dawsh/marzneshin:latest dawsh/marzneshin:${{github.ref_name}} - + - name: Nightly build and push for docker if: ${{ github.event_name == 'schedule' || github.event.inputs.build-target == 'nightly'}} uses: docker/build-push-action@v5 @@ -90,7 +90,7 @@ jobs: push: true tags: | dawsh/marzneshin:nightly - + - name: Update Nightly Release if: ${{ github.event_name == 'schedule' || github.event.inputs.build-target == 'nightly'}} uses: andelf/nightly-release@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f4d3239..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Release - -on: - push: - branches: - - 'main' - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v2 - - - name: Release with release-please - uses: google-github-actions/release-please-action@v2 - with: - release-type: simple - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/db/crud.py b/app/db/crud.py index 1878726..197684a 100644 --- a/app/db/crud.py +++ b/app/db/crud.py @@ -22,7 +22,12 @@ from app.db.models import ( User, ) from app.models.admin import AdminCreate, AdminModify, AdminPartialModify -from app.models.node import NodeCreate, NodeModify, NodeStatus, NodeUsageResponse +from app.models.node import ( + NodeCreate, + NodeModify, + NodeStatus, + NodeUsageResponse, +) from app.models.proxy import InboundHost as InboundHostModify from app.models.service import Service as ServiceModify, ServiceCreate from app.models.user import ( @@ -33,7 +38,10 @@ from app.models.user import ( UserStatus, UserUsageResponse, ) -from app.utils.helpers import calculate_expiration_days, calculate_usage_percent +from app.utils.helpers import ( + calculate_expiration_days, + calculate_usage_percent, +) from config import NOTIFY_DAYS_LEFT, NOTIFY_REACHED_USAGE_PERCENT @@ -72,8 +80,13 @@ def assure_node_inbounds(db: Session, inbounds: List[Inbound], node_id: int): if inb.tag in current_tags: stmt = ( update(Inbound) - .where(and_(Inbound.node_id == node_id, Inbound.tag == inb.tag)) - .values(protocol=json.loads(inb.config)["protocol"], config=inb.config) + .where( + and_(Inbound.node_id == node_id, Inbound.tag == inb.tag) + ) + .values( + protocol=json.loads(inb.config)["protocol"], + config=inb.config, + ) ) db.execute(stmt) else: @@ -96,7 +109,9 @@ def assure_node_inbounds(db: Session, inbounds: List[Inbound], node_id: int): def get_node_users( - db: Session, node_id: Optional[int], statuses: Optional[List[UserStatus]] = None + db: Session, + node_id: Optional[int], + statuses: Optional[List[UserStatus]] = None, ): query = ( db.query(User.id, User.username, User.key, Inbound) @@ -124,7 +139,11 @@ def get_user_hosts(db: Session, user_id: int): def get_inbound_hosts(db: Session, inbound_id: int) -> List[InboundHost]: - return db.query(InboundHost).filter(InboundHost.inbound_id == inbound_id).all() + return ( + db.query(InboundHost) + .filter(InboundHost.inbound_id == inbound_id) + .all() + ) def get_all_inbounds(db: Session): @@ -229,9 +248,13 @@ def get_users( if reset_strategy: if isinstance(reset_strategy, list): - query = query.filter(User.data_limit_reset_strategy.in_(reset_strategy)) + query = query.filter( + User.data_limit_reset_strategy.in_(reset_strategy) + ) else: - query = query.filter(User.data_limit_reset_strategy == reset_strategy) + query = query.filter( + User.data_limit_reset_strategy == reset_strategy + ) if admin: query = query.filter(User.admin == admin) @@ -338,16 +361,23 @@ def update_user(db: Session, dbuser: User, modify: UserModify): if modify.data_limit is not None: # in case there is modification to a user's data limit - dbuser.data_limit = modify.data_limit or None # set it to the new limit + dbuser.data_limit = ( + modify.data_limit or None + ) # set it to the new limit if dbuser.status is not UserStatus.expired: # in case the user isn't already disabled/expired - if not dbuser.data_limit or dbuser.used_traffic < dbuser.data_limit: + if ( + not dbuser.data_limit + or dbuser.used_traffic < dbuser.data_limit + ): # in case the user is unlimited or hasn't reached the limit already if dbuser.status != UserStatus.on_hold: dbuser.status = UserStatus.active if not dbuser.data_limit or ( - calculate_usage_percent(dbuser.used_traffic, dbuser.data_limit) + calculate_usage_percent( + dbuser.used_traffic, dbuser.data_limit + ) < NOTIFY_REACHED_USAGE_PERCENT ): delete_notification_reminder_by_type( @@ -375,7 +405,9 @@ def update_user(db: Session, dbuser: User, modify: UserModify): dbuser.note = modify.note or None if modify.data_limit_reset_strategy is not None: - dbuser.data_limit_reset_strategy = modify.data_limit_reset_strategy.value + dbuser.data_limit_reset_strategy = ( + modify.data_limit_reset_strategy.value + ) if modify.on_hold_timeout is not None: dbuser.on_hold_timeout = modify.on_hold_timeout @@ -460,7 +492,9 @@ def set_owner(db: Session, dbuser: User, admin: Admin): def start_user_expire(db: Session, dbuser: User): - expire = datetime.utcnow() + timedelta(seconds=dbuser.on_hold_expire_duration) + expire = datetime.utcnow() + timedelta( + seconds=dbuser.on_hold_expire_duration + ) dbuser.expire = expire db.commit() db.refresh(dbuser) @@ -547,7 +581,9 @@ 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.inbound_ids)).all(), + inbounds=db.query(Inbound) + .filter(Inbound.id.in_(service.inbound_ids)) + .all(), users=[], ) db.add(dbservice) @@ -564,13 +600,17 @@ def get_services(db: Session) -> List[Service]: return db.query(Service).all() -def update_service(db: Session, db_service: Service, modification: ServiceModify): +def update_service( + db: Session, db_service: Service, modification: ServiceModify +): if modification.name is not None: db_service.name = modification.name if modification.inbound_ids is not None: db_service.inbounds = ( - db.query(Inbound).filter(Inbound.id.in_(modification.inbound_ids)).all() + db.query(Inbound) + .filter(Inbound.id.in_(modification.inbound_ids)) + .all() ) db.commit() @@ -593,7 +633,9 @@ def get_node_by_id(db: Session, node_id: int): def get_nodes( - db: Session, status: Optional[Union[NodeStatus, list]] = None, enabled: bool = None + db: Session, + status: Optional[Union[NodeStatus, list]] = None, + enabled: bool = None, ): query = db.query(Node) @@ -698,7 +740,10 @@ def update_node_status( def create_notification_reminder( - db: Session, reminder_type: ReminderType, expires_at: datetime, user_id: int + db: Session, + reminder_type: ReminderType, + expires_at: datetime, + user_id: int, ) -> NotificationReminder: reminder = NotificationReminder( type=reminder_type, expires_at=expires_at, user_id=user_id @@ -742,7 +787,9 @@ def delete_notification_reminder_by_type( return -def delete_notification_reminder(db: Session, dbreminder: NotificationReminder) -> None: +def delete_notification_reminder( + db: Session, dbreminder: NotificationReminder +) -> None: db.delete(dbreminder) db.commit() return diff --git a/app/db/migrations/env.py b/app/db/migrations/env.py index b9115f1..4ee6e67 100644 --- a/app/db/migrations/env.py +++ b/app/db/migrations/env.py @@ -68,7 +68,9 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, target_metadata=target_metadata + ) with context.begin_transaction(): context.run_migrations() diff --git a/app/db/migrations/versions/20faa9f18c0a_init.py b/app/db/migrations/versions/20faa9f18c0a_init.py index a004ba3..bb29011 100644 --- a/app/db/migrations/versions/20faa9f18c0a_init.py +++ b/app/db/migrations/versions/20faa9f18c0a_init.py @@ -30,7 +30,9 @@ def upgrade() -> None: sa.Column("password_reset_at", sa.DateTime(), nullable=True), sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f("ix_admins_username"), "admins", ["username"], unique=True) + op.create_index( + op.f("ix_admins_username"), "admins", ["username"], unique=True + ) jwttable = op.create_table( "jwt", @@ -104,14 +106,18 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) tls = generate_certificate() - op.bulk_insert(tlstable, [{"id": 1, "key": tls["key"], "certificate": tls["cert"]}]) + op.bulk_insert( + tlstable, [{"id": 1, "key": tls["key"], "certificate": tls["cert"]}] + ) op.create_table( "inbounds", sa.Column("id", sa.Integer(), nullable=False), sa.Column( "protocol", - sa.Enum("VMess", "VLESS", "Trojan", "Shadowsocks", name="proxytypes"), + sa.Enum( + "VMess", "VLESS", "Trojan", "Shadowsocks", name="proxytypes" + ), nullable=True, ), sa.Column("tag", sa.String(length=256), nullable=False), @@ -124,7 +130,9 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("node_id", "tag"), ) - op.create_index(op.f("ix_inbounds_node_id"), "inbounds", ["node_id"], unique=False) + op.create_index( + op.f("ix_inbounds_node_id"), "inbounds", ["node_id"], unique=False + ) op.create_table( "node_usages", sa.Column("id", sa.Integer(), nullable=False), @@ -146,7 +154,9 @@ def upgrade() -> None: sa.Column("key", sa.String(length=64), nullable=True), sa.Column( "status", - sa.Enum("active", "limited", "expired", "on_hold", name="userstatus"), + sa.Enum( + "active", "limited", "expired", "on_hold", name="userstatus" + ), nullable=False, ), sa.Column("used_traffic", sa.BigInteger(), nullable=True), @@ -182,7 +192,9 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("key"), ) - op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + op.create_index( + op.f("ix_users_username"), "users", ["username"], unique=True + ) op.create_table( "hosts", sa.Column("id", sa.Integer(), nullable=False), @@ -194,12 +206,16 @@ def upgrade() -> None: sa.Column("host", sa.String(length=1024), nullable=True), sa.Column( "security", - sa.Enum("inbound_default", "none", "tls", name="inboundhostsecurity"), + sa.Enum( + "inbound_default", "none", "tls", name="inboundhostsecurity" + ), nullable=False, ), sa.Column( "alpn", - sa.Enum("none", "h2", "http/1.1", "h2,http/1.1", name="proxyhostalpn"), + sa.Enum( + "none", "h2", "http/1.1", "h2,http/1.1", name="proxyhostalpn" + ), server_default="none", nullable=False, ), diff --git a/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py b/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py index 043cbef..0713097 100644 --- a/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py +++ b/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py @@ -21,7 +21,9 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column( "hosts", - sa.Column("mux", sa.Boolean(), server_default=sa.sql.true(), nullable=False), + sa.Column( + "mux", sa.Boolean(), server_default=sa.sql.true(), nullable=False + ), ) op.add_column("hosts", sa.Column("fragment", sa.JSON(), nullable=True)) # ### end Alembic commands ### diff --git a/app/db/migrations/versions/e8325f600a49_user_enable.py b/app/db/migrations/versions/e8325f600a49_user_enable.py index d1b2300..cad17db 100644 --- a/app/db/migrations/versions/e8325f600a49_user_enable.py +++ b/app/db/migrations/versions/e8325f600a49_user_enable.py @@ -22,7 +22,10 @@ def upgrade() -> None: op.add_column( "users", sa.Column( - "enabled", sa.Boolean(), server_default=sa.sql.true(), nullable=False + "enabled", + sa.Boolean(), + server_default=sa.sql.true(), + nullable=False, ), ) # ### end Alembic commands ### diff --git a/app/db/models.py b/app/db/models.py index 255d5b8..096c95b 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -27,7 +27,11 @@ from app.models.proxy import ( InboundHostSecurity, ProxyTypes, ) -from app.models.user import ReminderType, UserDataLimitResetStrategy, UserStatus +from app.models.user import ( + ReminderType, + UserDataLimitResetStrategy, + UserStatus, +) class Admin(Base): @@ -61,7 +65,9 @@ class Service(Base): __tablename__ = "services" id = Column(Integer, primary_key=True) name = Column(String(64)) - users = relationship("User", secondary=users_services, back_populates="services") + users = relationship( + "User", secondary=users_services, back_populates="services" + ) inbounds = relationship( "Inbound", secondary=inbounds_services, back_populates="services" ) @@ -82,10 +88,16 @@ class User(Base): username = Column(String(32), unique=True, index=True) key = Column(String(64), unique=True) enabled = Column( - Boolean, nullable=False, default=True, server_default=sqlalchemy.sql.true() + Boolean, + nullable=False, + default=True, + server_default=sqlalchemy.sql.true(), ) services = relationship( - "Service", secondary=users_services, back_populates="users", lazy="joined" + "Service", + secondary=users_services, + back_populates="users", + lazy="joined", ) inbounds = relationship( "Inbound", @@ -95,7 +107,9 @@ class User(Base): distinct_target_key=True, ) # proxies = relationship("Proxy", back_populates="user", cascade="all, delete-orphan") - status = Column(Enum(UserStatus), nullable=False, default=UserStatus.active) + status = Column( + Enum(UserStatus), nullable=False, default=UserStatus.active + ) used_traffic = Column(BigInteger, default=0) lifetime_used_traffic = Column( BigInteger, default=0, server_default="0", nullable=False @@ -150,7 +164,9 @@ class Inbound(Base): "Service", secondary=inbounds_services, back_populates="inbounds" ) hosts = relationship( - "InboundHost", back_populates="inbound", cascade="all, delete, delete-orphan" + "InboundHost", + back_populates="inbound", + cascade="all, delete, delete-orphan", ) @property @@ -187,7 +203,10 @@ class InboundHost(Base): ) mux = Column( - Boolean, default=False, nullable=False, server_default=sqlalchemy.sql.true() + Boolean, + default=False, + nullable=False, + server_default=sqlalchemy.sql.true(), ) fragment = Column(JSON()) @@ -231,18 +250,26 @@ class Node(Base): address = Column(String(256)) port = Column(Integer) xray_version = Column(String(32)) - inbounds = relationship("Inbound", back_populates="node", cascade="all, delete") - status = Column(Enum(NodeStatus), nullable=False, default=NodeStatus.unhealthy) + inbounds = relationship( + "Inbound", back_populates="node", cascade="all, delete" + ) + status = Column( + Enum(NodeStatus), nullable=False, default=NodeStatus.unhealthy + ) last_status_change = Column(DateTime, default=datetime.utcnow) message = Column(String(1024)) created_at = Column(DateTime, default=datetime.utcnow) uplink = Column(BigInteger, default=0) downlink = Column(BigInteger, default=0) user_usages = relationship( - "NodeUserUsage", back_populates="node", cascade="all, delete, delete-orphan" + "NodeUserUsage", + back_populates="node", + cascade="all, delete, delete-orphan", ) usages = relationship( - "NodeUsage", back_populates="node", cascade="all, delete, delete-orphan" + "NodeUsage", + back_populates="node", + cascade="all, delete, delete-orphan", ) usage_coefficient = Column( Float, nullable=False, server_default=text("1.0"), default=1 diff --git a/app/dependencies.py b/app/dependencies.py index 83098e7..3c95823 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -76,7 +76,8 @@ def get_user( ): db_user = crud.get_user(db, username) if not ( - admin.is_sudo or (db_user.admin and db_user.admin.username == admin.username) + admin.is_sudo + or (db_user.admin and db_user.admin.username == admin.username) ): raise HTTPException(status_code=403, detail="You're not allowed") @@ -88,7 +89,9 @@ def get_user( def parse_start_date(start: str | None = None): if not start: - return datetime.fromtimestamp(datetime.utcnow().timestamp() - 30 * 24 * 3600) + return datetime.fromtimestamp( + datetime.utcnow().timestamp() - 30 * 24 * 3600 + ) else: return datetime.fromisoformat(start) diff --git a/app/marzneshin.py b/app/marzneshin.py index 0c322fc..9fc5937 100644 --- a/app/marzneshin.py +++ b/app/marzneshin.py @@ -68,14 +68,22 @@ app.add_middleware( scheduler = AsyncIOScheduler(timezone="UTC") scheduler.add_job(record_user_usages, "interval", coalesce=True, seconds=30) -scheduler.add_job(review_users, "interval", seconds=600, coalesce=True, max_instances=1) +scheduler.add_job( + review_users, "interval", seconds=600, coalesce=True, max_instances=1 +) scheduler.add_job(reset_user_data_usage, "interval", coalesce=True, hours=1) scheduler.add_job( - record_realtime_bandwidth, "interval", seconds=1, coalesce=True, max_instances=1 + record_realtime_bandwidth, + "interval", + seconds=1, + coalesce=True, + max_instances=1, ) if config.WEBHOOK_ADDRESS: - scheduler.add_job(send_notifications, "interval", seconds=30, replace_existing=True) + scheduler.add_job( + send_notifications, "interval", seconds=30, replace_existing=True + ) scheduler.add_job( delete_expired_reminders, "interval", @@ -97,7 +105,9 @@ async def on_shutdown(): @app.exception_handler(RequestValidationError) -def validation_exception_handler(request: Request, exc: RequestValidationError): +def validation_exception_handler( + request: Request, exc: RequestValidationError +): details = {} for error in exc.errors(): details[error["loc"][-1]] = error.get("msg") diff --git a/app/marznode/__init__.py b/app/marznode/__init__.py index c7a9aac..aa1f561 100644 --- a/app/marznode/__init__.py +++ b/app/marznode/__init__.py @@ -10,4 +10,10 @@ from .grpclib import MarzNodeGRPCLIB nodes: Dict[int, MarzNodeBase] = {} -__all__ = ["nodes", "operations", "MarzNodeGRPCIO", "MarzNodeGRPCLIB", "MarzNodeBase"] +__all__ = [ + "nodes", + "operations", + "MarzNodeGRPCIO", + "MarzNodeGRPCLIB", + "MarzNodeBase", +] diff --git a/app/marznode/base.py b/app/marznode/base.py index d7f73a9..68a0509 100644 --- a/app/marznode/base.py +++ b/app/marznode/base.py @@ -3,7 +3,9 @@ from abc import ABC class MarzNodeBase(ABC): - async def update_user(self, user, inbounds: list[str] | None = None) -> None: + async def update_user( + self, user, inbounds: list[str] | None = None + ) -> None: """updates a user on the node""" async def fetch_users_stats(self): diff --git a/app/marznode/grpcio.py b/app/marznode/grpcio.py index e3889db..7d43ffb 100644 --- a/app/marznode/grpcio.py +++ b/app/marznode/grpcio.py @@ -68,7 +68,9 @@ class MarzNodeGRPCIO(MarzNodeBase, MarzNodeDB): if state != ChannelConnectivity.READY: raise RpcError await self._sync() - self._streaming_task = asyncio.create_task(self._stream_user_updates()) + self._streaming_task = asyncio.create_task( + self._stream_user_updates() + ) except RpcError: self.synced = False self.set_status(NodeStatus.unhealthy) @@ -90,8 +92,12 @@ class MarzNodeGRPCIO(MarzNodeBase, MarzNodeDB): try: await stream.write( UserData( - user=User(id=user.id, username=user.username, key=user.key), - inbounds=[Inbound(tag=t) for t in user_update["inbounds"]], + user=User( + id=user.id, username=user.username, key=user.key + ), + inbounds=[ + Inbound(tag=t) for t in user_update["inbounds"] + ], ) ) except RpcError: diff --git a/app/marznode/grpclib.py b/app/marznode/grpclib.py index 1046cb5..c048bbf 100644 --- a/app/marznode/grpclib.py +++ b/app/marznode/grpclib.py @@ -95,8 +95,14 @@ class MarzNodeGRPCLIB(MarzNodeBase, MarzNodeDB): user = user_update["user"] await stream.send_message( UserData( - user=User(id=user.id, username=user.username, key=user.key), - inbounds=[Inbound(tag=t) for t in user_update["inbounds"]], + user=User( + id=user.id, + username=user.username, + key=user.key, + ), + inbounds=[ + Inbound(tag=t) for t in user_update["inbounds"] + ], ) ) except (OSError, ConnectionError, GRPCError, StreamTerminatedError): @@ -137,7 +143,9 @@ class MarzNodeGRPCLIB(MarzNodeBase, MarzNodeDB): async def get_logs(self, include_buffer=True): async with self._stub.StreamXrayLogs.open() as stm: - await stm.send_message(XrayLogsRequest(include_buffer=include_buffer)) + await stm.send_message( + XrayLogsRequest(include_buffer=include_buffer) + ) while True: response = await stm.recv_message() yield response.line diff --git a/app/models/admin.py b/app/models/admin.py index 9c99b0b..2f13ddd 100644 --- a/app/models/admin.py +++ b/app/models/admin.py @@ -5,7 +5,9 @@ from passlib.context import CryptContext from pydantic import ConfigDict, BaseModel pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admins/token") # Admin view url +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="/api/admins/token" +) # Admin view url class Token(BaseModel): @@ -37,7 +39,9 @@ class AdminModify(BaseModel): class AdminPartialModify(AdminModify): - __annotations__ = {k: Optional[v] for k, v in AdminModify.__annotations__.items()} + __annotations__ = { + k: Optional[v] for k, v in AdminModify.__annotations__.items() + } class AdminInDB(Admin): diff --git a/app/models/service.py b/app/models/service.py index 5a8f119..0beb03a 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -23,7 +23,11 @@ class ServiceModify(Service): inbound_ids: list[int] | None = Field(None) model_config = ConfigDict( json_schema_extra={ - "example": {"id": 2, "name": "my service 2", "inbound_ids": [1, 2, 3]} + "example": { + "id": 2, + "name": "my service 2", + "inbound_ids": [1, 2, 3], + } } ) diff --git a/app/models/user.py b/app/models/user.py index 478d416..9046035 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -50,7 +50,9 @@ class UserDataLimitResetStrategy(str, Enum): class User(BaseModel): id: int | None = None - username: Annotated[str, StringConstraints(to_lower=True, pattern=USERNAME_REGEXP)] + 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: int | None = Field( @@ -97,11 +99,15 @@ class UserCreate(User): "User cannot be on hold without a valid on_hold_expire_duration." ) if self.expire: - raise ValueError("User cannot be on hold with specified expire.") + raise ValueError( + "User cannot be on hold with specified expire." + ) elif self.status == UserStatusCreate.active and ( self.on_hold_expire_duration or self.on_hold_timeout ): - raise ValueError("on hold parametrs set when user status isn't on_hold.") + raise ValueError( + "on hold parametrs set when user status isn't on_hold." + ) return self @field_validator("expire") @@ -112,7 +118,9 @@ class UserCreate(User): "Expire date should be offset naive, and preferably in utc timezone." ) if isinstance(v, datetime) and v < datetime.utcnow(): - raise ValueError("Expire date should be in the future, not in the past.") + raise ValueError( + "Expire date should be in the future, not in the past." + ) return v @@ -152,7 +160,9 @@ class UserModify(User): "Expire date should be offset naive, and preferably in utc timezone." ) if isinstance(v, datetime) and v < datetime.utcnow(): - raise ValueError("Expire date should be in the future, not in the past.") + raise ValueError( + "Expire date should be in the future, not in the past." + ) return v diff --git a/app/routes/admin.py b/app/routes/admin.py index aa78d6b..4932dd4 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -16,13 +16,17 @@ from app.utils.jwt import create_admin_token router = APIRouter(tags=["Admin"], prefix="/admins") -def authenticate_admin(db: Session, username: str, password: str) -> Optional[Admin]: +def authenticate_admin( + db: Session, username: str, password: str +) -> Optional[Admin]: dbadmin = crud.get_admin(db, username) if not dbadmin: return None return ( - dbadmin if AdminInDB.model_validate(dbadmin).verify_password(password) else None + dbadmin + if AdminInDB.model_validate(dbadmin).verify_password(password) + else None ) @@ -51,10 +55,16 @@ def get_current_admin(admin: AdminDep): @router.post("/token", response_model=Token) -def admin_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: DBDep): - if dbadmin := authenticate_admin(db, form_data.username, form_data.password): +def admin_token( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: DBDep +): + if dbadmin := authenticate_admin( + db, form_data.username, form_data.password + ): return Token( - access_token=create_admin_token(form_data.username, is_sudo=dbadmin.is_sudo) + access_token=create_admin_token( + form_data.username, is_sudo=dbadmin.is_sudo + ) ) raise HTTPException( @@ -72,8 +82,12 @@ def modify_admin( raise HTTPException(status_code=403, detail="You're not allowed") # If a non-sudoer admin is making itself a sudoer - if (admin.username == username) and (modified_admin.is_sudo and not admin.is_sudo): - raise HTTPException(status_code=403, detail="You can't make yourself sudoer!") + if (admin.username == username) and ( + modified_admin.is_sudo and not admin.is_sudo + ): + raise HTTPException( + status_code=403, detail="You can't make yourself sudoer!" + ) dbadmin = crud.get_admin(db, username) if not dbadmin: diff --git a/app/routes/inbounds.py b/app/routes/inbounds.py index 691c033..58018d7 100644 --- a/app/routes/inbounds.py +++ b/app/routes/inbounds.py @@ -78,7 +78,9 @@ def get_inbound_hosts(id: int | None, db: DBDep): if not inbound: raise HTTPException(status_code=404, detail="Inbound not found") - return paginate(db, db.query(DBInboundHost).filter(DBInboundHost.inbound_id == id)) + 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 f2f5465..bdaa9f7 100644 --- a/app/routes/node.py +++ b/app/routes/node.py @@ -12,7 +12,13 @@ from starlette.websockets import WebSocketDisconnect from app import marznode from app.db import crud, get_tls_certificate from app.db.models import Node -from app.dependencies import DBDep, SudoAdminDep, EndDateDep, StartDateDep, get_admin +from app.dependencies import ( + DBDep, + SudoAdminDep, + EndDateDep, + StartDateDep, + get_admin, +) from app.models.node import ( NodeCreate, NodeModify, @@ -70,7 +76,10 @@ def get_node_settings(db: DBDep, admin: SudoAdminDep): @router.get("/usage", response_model=NodesUsageResponse) def get_usage( - db: DBDep, admin: SudoAdminDep, start_date: StartDateDep, end_date: EndDateDep + db: DBDep, + admin: SudoAdminDep, + start_date: StartDateDep, + end_date: EndDateDep, ): """ Get nodes usage diff --git a/app/routes/service.py b/app/routes/service.py index 7f977a8..07ab278 100644 --- a/app/routes/service.py +++ b/app/routes/service.py @@ -79,7 +79,11 @@ def get_service_inbounds(id: int, db: DBDep): if not service: raise HTTPException(status_code=404, detail="Service not found") - query = db.query(Inbound).join(Inbound.services).where(Service.id == service.id) + query = ( + db.query(Inbound) + .join(Inbound.services) + .where(Service.id == service.id) + ) return paginate(query) @@ -101,10 +105,14 @@ async def modify_service(id: int, modification: ServiceModify, db: DBDep): response = crud.update_service(db, dbservice, modification) except sqlalchemy.exc.IntegrityError: db.rollback() - raise HTTPException(status_code=409, detail="problem updating the service") + raise HTTPException( + status_code=409, detail="problem updating the service" + ) else: for user in response.users: - await marznode.operations.update_user(user, old_inbounds=old_inbounds) + await marznode.operations.update_user( + user, old_inbounds=old_inbounds + ) return response diff --git a/app/routes/subscription.py b/app/routes/subscription.py index 75e5ea6..ca993da 100644 --- a/app/routes/subscription.py +++ b/app/routes/subscription.py @@ -43,9 +43,13 @@ def user_subscription( user: UserResponse = UserResponse.model_validate(db_user) if "text/html" in accept_header: - links = generate_subscription(user=db_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}) + render_template( + SUBSCRIPTION_PAGE_TEMPLATE, {"user": user, "links": links} + ) ) response_headers = { @@ -65,20 +69,28 @@ def user_subscription( if re.match("^([Cc]lash-verge|[Cc]lash-?[Mm]eta)", user_agent): conf = generate_subscription(user=db_user, config_format="clash-meta") - return Response(content=conf, media_type="text/yaml", headers=response_headers) + return Response( + content=conf, media_type="text/yaml", headers=response_headers + ) elif re.match("^([Cc]lash|[Ss]tash)", user_agent): conf = generate_subscription(user=db_user, config_format="clash") - return Response(content=conf, media_type="text/yaml", headers=response_headers) + return Response( + content=conf, media_type="text/yaml", headers=response_headers + ) elif re.match("^(SFA|SFI|SFM|SFT)", user_agent): conf = generate_subscription(user=db_user, config_format="sing-box") return Response( - content=conf, media_type="application/json", headers=response_headers + content=conf, + media_type="application/json", + headers=response_headers, ) else: conf = generate_subscription( user=db_user, config_format="v2ray", as_base64=True ) - return Response(content=conf, media_type="text/plain", headers=response_headers) + return Response( + content=conf, media_type="text/plain", headers=response_headers + ) @router.get("/{username}/{key}/info", response_model=UserResponse) @@ -88,7 +100,10 @@ def user_subscription_info(db_user: SubUserDep): @router.get("/{username}/{key}/usage") def user_get_usage( - db_user: SubUserDep, db: DBDep, start_date: StartDateDep, end_date: EndDateDep + db_user: SubUserDep, + db: DBDep, + start_date: StartDateDep, + end_date: EndDateDep, ): usages = crud.get_user_usages(db, db_user, start_date, end_date) @@ -122,26 +137,36 @@ def user_subscription_with_client_type( if client_type == "clash-meta": conf = generate_subscription(user=db_user, config_format="clash-meta") - return Response(content=conf, media_type="text/yaml", headers=response_headers) + return Response( + content=conf, media_type="text/yaml", headers=response_headers + ) elif client_type == "sing-box": conf = generate_subscription(user=db_user, config_format="sing-box") return Response( - content=conf, media_type="application/json", headers=response_headers + content=conf, + media_type="application/json", + headers=response_headers, ) elif client_type == "clash": conf = generate_subscription(user=db_user, config_format="clash") - return Response(content=conf, media_type="text/yaml", headers=response_headers) + return Response( + content=conf, media_type="text/yaml", headers=response_headers + ) elif client_type == "v2ray": conf = generate_subscription( user=db_user, config_format="v2ray", as_base64=True ) - return Response(content=conf, media_type="text/plain", headers=response_headers) + return Response( + content=conf, media_type="text/plain", headers=response_headers + ) elif client_type == "xray": return Response( content=generate_subscription(user=db_user, config_format="xray"), headers=response_headers, ) else: - raise HTTPException(status_code=400, detail="Invalid subscription type") + raise HTTPException( + status_code=400, detail="Invalid subscription type" + ) diff --git a/app/routes/system.py b/app/routes/system.py index 97e65ad..561cbcb 100644 --- a/app/routes/system.py +++ b/app/routes/system.py @@ -21,17 +21,25 @@ def get_admins_stats(db: DBDep, admin: SudoAdminDep): def get_nodes_stats(db: DBDep, admin: SudoAdminDep): return NodesStats( total=db.query(Node).count(), - healthy=db.query(Node).filter(Node.status == NodeStatus.healthy).count(), - unhealthy=db.query(Node).filter(Node.status == NodeStatus.unhealthy).count(), + healthy=db.query(Node) + .filter(Node.status == NodeStatus.healthy) + .count(), + unhealthy=db.query(Node) + .filter(Node.status == NodeStatus.unhealthy) + .count(), ) @router.get("/stats/users", response_model=UsersStats) def get_users_stats(db: DBDep, admin: AdminDep): return UsersStats( - total=crud.get_users_count(db, admin=admin if not admin.is_sudo else None), + total=crud.get_users_count( + db, admin=admin if not admin.is_sudo else None + ), active=crud.get_users_count( - db, admin=admin if not admin.is_sudo else None, status=UserStatus.active + db, + admin=admin if not admin.is_sudo else None, + status=UserStatus.active, ), on_hold=crud.get_users_count( db, diff --git a/app/routes/user.py b/app/routes/user.py index b160b8c..33c5825 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -102,7 +102,9 @@ async def add_user(new_user: UserCreate, db: DBDep, admin: AdminDep): user = UserResponse.model_validate(db_user) await marznode.operations.update_user(user=db_user) - asyncio.create_task(report.user_created(user=user, user_id=db_user.id, by=admin)) + asyncio.create_task( + report.user_created(user=user, user_id=db_user.id, by=admin) + ) logger.info("New user `%s` added", db_user.username) return user @@ -147,7 +149,9 @@ def delete_expired(passed_time: int, db: DBDep, admin: AdminDep): for db_user in expired_users: crud.remove_user(db, db_user) - asyncio.create_task(report.user_deleted(username=db_user.username, by=admin)) + asyncio.create_task( + report.user_deleted(username=db_user.username, by=admin) + ) logger.info("User `%s` removed", db_user.username) @@ -221,7 +225,9 @@ async def remove_user(db_user: UserDep, db: DBDep, admin: AdminDep): crud.remove_user(db, db_user) db.flush() - asyncio.create_task(report.user_deleted(username=db_user.username, by=admin)) + asyncio.create_task( + report.user_deleted(username=db_user.username, by=admin) + ) logger.info("User %s deleted", db_user.username) return {} @@ -236,7 +242,9 @@ def get_user_services(username: str, db: DBDep): if not user: raise HTTPException(status_code=404, detail="User not found") - query = db.query(Service).join(Service.users).where(User.username == username) + query = ( + db.query(Service).join(Service.users).where(User.username == username) + ) return paginate(query) @@ -249,7 +257,10 @@ async def reset_user_data_usage(db_user: UserDep, db: DBDep, admin: AdminDep): previous_status = db_user.status db_user = crud.reset_user_data_usage(db, db_user) - if db_user.status == UserStatus.active and previous_status == UserStatus.limited: + if ( + db_user.status == UserStatus.active + and previous_status == UserStatus.limited + ): await marznode.operations.update_user(db_user) user = UserResponse.model_validate(db_user) @@ -299,7 +310,9 @@ async def disable_user(db_user: UserDep, db: DBDep, admin: AdminDep): @router.post("/{username}/revoke_sub", response_model=UserResponse) -async def revoke_user_subscription(db_user: UserDep, db: DBDep, admin: AdminDep): +async def revoke_user_subscription( + db_user: UserDep, db: DBDep, admin: AdminDep +): """ Revoke users subscription (Subscription link and proxies) """ @@ -329,7 +342,9 @@ def get_user_usage( @router.put("/{username}/set-owner", response_model=UserResponse) -def set_owner(username: str, admin_username: str, db: DBDep, admin: SudoAdminDep): +def set_owner( + username: str, admin_username: str, db: DBDep, admin: SudoAdminDep +): db_user = crud.get_user(db, username) if not db_user: raise HTTPException(status_code=404, detail="User not found") @@ -341,6 +356,8 @@ def set_owner(username: str, admin_username: str, db: DBDep, admin: SudoAdminDep db_user = crud.set_owner(db, db_user, new_admin) user = UserResponse.model_validate(db_user) - logger.info("`%s`'s owner successfully set to `%s`", user.username, admin.username) + logger.info( + "`%s`'s owner successfully set to `%s`", user.username, admin.username + ) return user diff --git a/app/tasks/record_usages.py b/app/tasks/record_usages.py index 30ddc56..cdecf01 100644 --- a/app/tasks/record_usages.py +++ b/app/tasks/record_usages.py @@ -11,17 +11,22 @@ from app.db.models import NodeUsage, NodeUserUsage, User from app.marznode import MarzNodeBase -def record_user_usage_logs(params: list, node_id: int, consumption_factor: int = 1): +def record_user_usage_logs( + params: list, node_id: int, consumption_factor: int = 1 +): if not params: return - created_at = datetime.fromisoformat(datetime.utcnow().strftime("%Y-%m-%dT%H:00:00")) + created_at = datetime.fromisoformat( + datetime.utcnow().strftime("%Y-%m-%dT%H:00:00") + ) with GetDB() as db: # make user usage row if doesn't exist select_stmt = select(NodeUserUsage.user_id).where( and_( - NodeUserUsage.node_id == node_id, NodeUserUsage.created_at == created_at + NodeUserUsage.node_id == node_id, + NodeUserUsage.created_at == created_at, ) ) existings = [r[0] for r in db.execute(select_stmt).fetchall()] @@ -67,13 +72,18 @@ def record_node_stats(node_id: int, usage: int): if not usage: return - created_at = datetime.fromisoformat(datetime.utcnow().strftime("%Y-%m-%dT%H:00:00")) + created_at = datetime.fromisoformat( + datetime.utcnow().strftime("%Y-%m-%dT%H:00:00") + ) with GetDB() as db: # make node usage row if doesn't exist select_stmt = select(NodeUsage.node_id).where( - and_(NodeUsage.node_id == node_id, NodeUsage.created_at == created_at) + and_( + NodeUsage.node_id == node_id, + NodeUsage.created_at == created_at, + ) ) notfound = db.execute(select_stmt).first() is None if notfound: @@ -89,7 +99,10 @@ def record_node_stats(node_id: int, usage: int): downlink=NodeUsage.downlink + usage, ) .where( - and_(NodeUsage.node_id == node_id, NodeUsage.created_at == created_at) + and_( + NodeUsage.node_id == node_id, + NodeUsage.created_at == created_at, + ) ) ) @@ -97,7 +110,9 @@ def record_node_stats(node_id: int, usage: int): db.commit() -async def get_users_stats(node_id: int, node: MarzNodeBase) -> tuple[int, list[dict]]: +async def get_users_stats( + node_id: int, node: MarzNodeBase +) -> tuple[int, list[dict]]: try: params = list() for stat in await node.fetch_users_stats(): @@ -112,14 +127,19 @@ async def record_user_usages(): # usage_coefficient = {None: 1} # default usage coefficient for the main api instance results = await asyncio.gather( - *[get_users_stats(node_id, node) for node_id, node in marznode.nodes.items()] + *[ + get_users_stats(node_id, node) + for node_id, node in marznode.nodes.items() + ] ) api_params = {node_id: params for node_id, params in list(results)} users_usage = defaultdict(int) for node_id, params in api_params.items(): coefficient = ( - node.usage_coefficient if (node := marznode.nodes.get(node_id)) else 1 + node.usage_coefficient + if (node := marznode.nodes.get(node_id)) + else 1 ) node_usage = 0 for param in params: @@ -143,12 +163,18 @@ async def record_user_usages(): online_at=datetime.utcnow(), ) - db.execute(stmt, users_usage, execution_options={"synchronize_session": None}) + db.execute( + stmt, users_usage, execution_options={"synchronize_session": None} + ) db.commit() for node_id, params in api_params.items(): record_user_usage_logs( params, node_id, - node.usage_coefficient if (node := marznode.nodes.get(node_id)) else 1, + ( + node.usage_coefficient + if (node := marznode.nodes.get(node_id)) + else 1 + ), ) diff --git a/app/tasks/reset_user_data_usage.py b/app/tasks/reset_user_data_usage.py index 3903b0b..3e009e5 100644 --- a/app/tasks/reset_user_data_usage.py +++ b/app/tasks/reset_user_data_usage.py @@ -28,7 +28,9 @@ async def reset_user_data_usage(): ], ): last_reset_time = user.traffic_reset_at or user.created_at - num_days_to_reset = reset_strategy_to_days[user.data_limit_reset_strategy] + num_days_to_reset = reset_strategy_to_days[ + user.data_limit_reset_strategy + ] if not (now - last_reset_time).days >= num_days_to_reset: continue diff --git a/app/tasks/review_users.py b/app/tasks/review_users.py index fb7a176..50549f8 100644 --- a/app/tasks/review_users.py +++ b/app/tasks/review_users.py @@ -15,8 +15,15 @@ from app.db import ( ) from app.models.user import ReminderType, UserResponse, UserStatus from app.utils import report -from app.utils.helpers import calculate_expiration_days, calculate_usage_percent -from config import NOTIFY_DAYS_LEFT, NOTIFY_REACHED_USAGE_PERCENT, WEBHOOK_ADDRESS +from app.utils.helpers import ( + calculate_expiration_days, + calculate_usage_percent, +) +from config import ( + NOTIFY_DAYS_LEFT, + NOTIFY_REACHED_USAGE_PERCENT, + WEBHOOK_ADDRESS, +) if TYPE_CHECKING: from app.db.models import User @@ -29,7 +36,9 @@ def add_notification_reminders( db: Session, user: "User", now: datetime = datetime.utcnow() ) -> None: if user.data_limit: - usage_percent = calculate_usage_percent(user.used_traffic, user.data_limit) + usage_percent = calculate_usage_percent( + user.used_traffic, user.data_limit + ) if (usage_percent >= NOTIFY_REACHED_USAGE_PERCENT) and ( not get_notification_reminder(db, user.id, ReminderType.data_usage) ): @@ -44,10 +53,16 @@ def add_notification_reminders( if user.expire and ((now - user.created_at).days >= NOTIFY_DAYS_LEFT): expire_days = calculate_expiration_days(user.expire) if (expire_days <= NOTIFY_DAYS_LEFT) and ( - not get_notification_reminder(db, user.id, ReminderType.expiration_date) + not get_notification_reminder( + db, user.id, ReminderType.expiration_date + ) ): report.expire_days_reached( - db, expire_days, UserResponse.model_validate(user), user.id, user.expire + db, + expire_days, + UserResponse.model_validate(user), + user.id, + user.expire, ) @@ -72,7 +87,9 @@ async def review_users(): asyncio.create_task( report.status_change( - user.username, user.status, UserResponse.model_validate(user) + user.username, + user.status, + UserResponse.model_validate(user), ) ) @@ -100,7 +117,9 @@ async def review_users(): start_user_expire(db, user) asyncio.create_task( report.status_change( - user.username, user.status, UserResponse.model_validate(user) + user.username, + user.status, + UserResponse.model_validate(user), ) ) logger.info(f"on hold user `{user.username}` has been activated") diff --git a/app/tasks/send_notifications.py b/app/tasks/send_notifications.py index 36e04df..f9aaac7 100644 --- a/app/tasks/send_notifications.py +++ b/app/tasks/send_notifications.py @@ -16,7 +16,11 @@ logger = logging.getLogger(__name__) session = Session() -headers = {"x-webhook-secret": config.WEBHOOK_SECRET} if config.WEBHOOK_SECRET else None +headers = ( + {"x-webhook-secret": config.WEBHOOK_SECRET} + if config.WEBHOOK_SECRET + else None +) async def send(data: List[Dict[Any, Any]]) -> bool: @@ -29,7 +33,9 @@ async def send(data: List[Dict[Any, Any]]) -> bool: bool: returns True if an ok response received """ try: - logger.debug(f"Sending {len(data)} webhook updates to {config.WEBHOOK_ADDRESS}") + logger.debug( + f"Sending {len(data)} webhook updates to {config.WEBHOOK_ADDRESS}" + ) r = await aiohttp.request( "post", config.WEBHOOK_ADDRESS, json=data, headers=headers @@ -62,14 +68,21 @@ async def send_notifications(): if not notifications_to_send: return - if not await send([jsonable_encoder(notif) for notif in notifications_to_send]): + if not await send( + [jsonable_encoder(notif) for notif in notifications_to_send] + ): for notification in notifications_to_send: - if (notification.tries + 1) > config.NUMBER_OF_RECURRENT_NOTIFICATIONS: + if ( + notification.tries + 1 + ) > config.NUMBER_OF_RECURRENT_NOTIFICATIONS: continue notification.tries += 1 - notification.send_at = ( # schedule notification for n seconds later - dt.utcnow() + td(seconds=config.RECURRENT_NOTIFICATIONS_TIMEOUT) - ).timestamp() + notification.send_at = ( + ( # schedule notification for n seconds later + dt.utcnow() + + td(seconds=config.RECURRENT_NOTIFICATIONS_TIMEOUT) + ).timestamp() + ) queue.append(notification) diff --git a/app/telegram/report.py b/app/telegram/report.py index 8a73630..70eaa61 100644 --- a/app/telegram/report.py +++ b/app/telegram/report.py @@ -21,7 +21,10 @@ async def report(message: str, parse_mode="html", keyboard=None): else: for admin in TELEGRAM_ADMIN_ID: await telegram.bot.send_message( - admin, message, parse_mode=parse_mode, reply_markup=keyboard + admin, + message, + parse_mode=parse_mode, + reply_markup=keyboard, ) except ApiTelegramException as e: logger.error(e) @@ -48,7 +51,9 @@ async def report_new_user( username=escape_html(username), data_limit=readable_size(data_limit) if data_limit else "Unlimited", expire_date=( - expire_date.strftime("%H:%M:%S %Y-%m-%d") if expire_date else "Never" + expire_date.strftime("%H:%M:%S %Y-%m-%d") + if expire_date + else "Never" ), services="" if not services else ", ".join([s.name for s in services]), ) @@ -57,7 +62,11 @@ async def report_new_user( async def report_user_modification( - username: str, expire_date: datetime, data_limit: int, services: list, by: str + username: str, + expire_date: datetime, + data_limit: int, + services: list, + by: str, ): text = """\ ✏️ #Modified @@ -73,7 +82,9 @@ async def report_user_modification( username=escape_html(username), data_limit=readable_size(data_limit) if data_limit else "Unlimited", expire_date=( - expire_date.strftime("%H:%M:%S %Y-%m-%d") if expire_date else "Never" + expire_date.strftime("%H:%M:%S %Y-%m-%d") + if expire_date + else "Never" ), services=", ".join([s.name for s in services]), ) diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 779e8e7..6f61f7c 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -26,7 +26,9 @@ def generate_certificate(): cert.set_issuer(cert.get_subject()) cert.set_pubkey(k) cert.sign(k, "sha512") - cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8") + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode( + "utf-8" + ) key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode("utf-8") return {"cert": cert_pem, "key": key_pem} diff --git a/app/utils/jwt.py b/app/utils/jwt.py index 09e882d..508625a 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -21,7 +21,9 @@ def create_admin_token(username: str, is_sudo=False) -> str: "iat": datetime.utcnow(), } if JWT_ACCESS_TOKEN_EXPIRE_MINUTES > 0: - expire = datetime.utcnow() + timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.utcnow() + timedelta( + minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES + ) data["exp"] = expire encoded_jwt = jwt.encode(data, get_secret_key(), algorithm="HS256") return encoded_jwt diff --git a/app/utils/report.py b/app/utils/report.py index b588c95..4cdbea4 100644 --- a/app/utils/report.py +++ b/app/utils/report.py @@ -23,7 +23,10 @@ from app.utils.notification import ( async def status_change( - username: str, status: UserStatus, user: UserResponse, by: Optional[Admin] = None + username: str, + status: UserStatus, + user: UserResponse, + by: Optional[Admin] = None, ) -> None: try: await telegram.report_status_change(username, status) @@ -32,13 +35,17 @@ async def status_change( if status == UserStatus.limited: await notify( UserLimited( - username=username, action=Notification.Type.user_limited, user=user + username=username, + action=Notification.Type.user_limited, + user=user, ) ) elif status == UserStatus.expired: await notify( UserExpired( - username=username, action=Notification.Type.user_expired, user=user + username=username, + action=Notification.Type.user_expired, + user=user, ) ) elif status == UserStatus.active: @@ -100,7 +107,9 @@ async def user_deleted(username: str, by: Admin) -> None: except Exception: pass await notify( - UserDeleted(username=username, action=Notification.Type.user_deleted, by=by) + UserDeleted( + username=username, action=Notification.Type.user_deleted, by=by + ) ) @@ -148,7 +157,9 @@ async def data_usage_percent_reached( expire: Optional[int] = None, ) -> None: await notify( - ReachedUsagePercent(username=user.username, user=user, used_percent=percent) + ReachedUsagePercent( + username=user.username, user=user, used_percent=percent + ) ) await create_notification_reminder( db, @@ -161,7 +172,9 @@ async def data_usage_percent_reached( async def expire_days_reached( db: Session, days: int, user: UserResponse, user_id: int, expire: int ) -> None: - await notify(ReachedDaysLeft(username=user.username, user=user, days_left=days)) + await notify( + ReachedDaysLeft(username=user.username, user=user, days_left=days) + ) await create_notification_reminder( db, ReminderType.expiration_date, diff --git a/app/utils/share.py b/app/utils/share.py index f1e2e61..2e6d6b0 100644 --- a/app/utils/share.py +++ b/app/utils/share.py @@ -9,7 +9,13 @@ from typing import Literal, Union, List from uuid import UUID from jdatetime import date as jd -from v2share import V2Data, SingBoxConfig, ClashConfig, ClashMetaConfig, XrayConfig +from v2share import ( + V2Data, + SingBoxConfig, + ClashConfig, + ClashMetaConfig, + XrayConfig, +) from v2share.links import LinksConfig from app.db import GetDB, crud @@ -73,7 +79,9 @@ def generate_subscription( subscription_handler_class = subscription_handlers[config_format] if template_path := handlers_templates[subscription_handler_class]: - subscription_handler = subscription_handler_class(template_path=template_path) + subscription_handler = subscription_handler_class( + template_path=template_path + ) else: subscription_handler = subscription_handler_class() @@ -81,7 +89,9 @@ def generate_subscription( inbounds, key, format_variables, conf=subscription_handler ) - return config if not as_base64 else base64.b64encode(config.encode()).decode() + return ( + config if not as_base64 else base64.b64encode(config.encode()).decode() + ) def format_time_left(seconds_left: int) -> str: @@ -118,7 +128,9 @@ def setup_format_variables(extra_data: dict) -> dict: seconds_left = (expire_datetime - dt.utcnow()).total_seconds() expire_date = expire_datetime.date() jalali_expire_date = jd.fromgregorian( - year=expire_date.year, month=expire_date.month, day=expire_date.day + year=expire_date.year, + month=expire_date.month, + day=expire_date.day, ).strftime("%Y-%m-%d") days_left = (expire_datetime - dt.utcnow()).days + 1 time_left = format_time_left(seconds_left) @@ -128,7 +140,10 @@ def setup_format_variables(extra_data: dict) -> dict: expire_date = "∞" jalali_expire_date = "∞" else: - if on_hold_expire_duration is not None and on_hold_expire_duration >= 0: + if ( + on_hold_expire_duration is not None + and on_hold_expire_duration >= 0 + ): days_left = timedelta(seconds=on_hold_expire_duration).days time_left = format_time_left(on_hold_expire_duration) expire_date = "-" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2e751e8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length = 79 +extend-exclude = ".*_(grpc|pb2).pyi?" \ No newline at end of file