Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions app/db/crud/hwid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from datetime import datetime, timezone

from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models import UserHWID


async def get_user_hwids(db: AsyncSession, user_id: int) -> list[UserHWID]:
"""Retrieve all HWIDs registered for a specific user."""
stmt = select(UserHWID).where(UserHWID.user_id == user_id).order_by(UserHWID.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())


async def get_user_hwid_by_value(db: AsyncSession, user_id: int, hwid_str: str) -> UserHWID | None:
"""Retrieve a specific HWID for a user by its value."""
stmt = select(UserHWID).where(UserHWID.user_id == user_id, UserHWID.hwid == hwid_str)
return (await db.execute(stmt)).scalar_one_or_none()


async def get_user_hwid_count(db: AsyncSession, user_id: int) -> int:
"""Count the number of HWIDs registered for a user."""
stmt = select(func.count(UserHWID.id)).where(UserHWID.user_id == user_id)
return (await db.execute(stmt)).scalar_one()


async def register_user_hwid(
db: AsyncSession,
user_id: int,
hwid: str,
device_os: str | None = None,
os_version: str | None = None,
device_model: str | None = None,
) -> UserHWID:
"""Register a new HWID for a user."""
new_hwid = UserHWID(
user_id=user_id,
hwid=hwid,
device_os=device_os[:256] if device_os else None,
os_version=os_version[:128] if os_version else None,
device_model=device_model[:256] if device_model else None,
)
db.add(new_hwid)
await db.commit()
await db.refresh(new_hwid)
return new_hwid
Comment on lines +44 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make HWID registration idempotent under concurrent requests.

Concurrent calls can hit the unique constraint and raise an integrity error; this should be handled as a normal duplicate-registration case instead of surfacing a failure.

🛡️ Proposed fix
 from sqlalchemy import delete, func, select
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
@@
     db.add(new_hwid)
-    await db.commit()
+    try:
+        await db.commit()
+    except IntegrityError:
+        await db.rollback()
+        existing = await get_user_hwid_by_value(db, user_id=user_id, hwid_str=hwid)
+        if existing is not None:
+            return existing
+        raise
     await db.refresh(new_hwid)
     return new_hwid
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
db.add(new_hwid)
await db.commit()
await db.refresh(new_hwid)
return new_hwid
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
db.add(new_hwid)
try:
await db.commit()
except IntegrityError:
await db.rollback()
existing = await get_user_hwid_by_value(db, user_id=user_id, hwid_str=hwid)
if existing is not None:
return existing
raise
await db.refresh(new_hwid)
return new_hwid
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/db/crud/hwid.py` around lines 44 - 47, Wrap the db.add(new_hwid) / await
db.commit() / await db.refresh(new_hwid) block in a try/except that catches
sqlalchemy.exc.IntegrityError, call await db.rollback() inside the except, then
query the DB for the existing record using the same unique key(s) (e.g. by
hardware_id or whatever unique column the model uses) and return that existing
instance instead of raising; ensure IntegrityError is imported and that you only
suppress duplicates (re-raise other DB errors). Keep references to the current
variables/new_hwid and use the model's unique lookup (e.g. get_by_hardware_id or
a query on the model) to return the already-registered object.



async def update_hwid_last_used(db: AsyncSession, hwid_obj: UserHWID) -> None:
"""Update the last_used_at timestamp for an HWID."""
hwid_obj.last_used_at = datetime.now(timezone.utc)
await db.commit()


async def delete_user_hwid(db: AsyncSession, user_id: int, hwid: str) -> bool:
"""Delete a specific HWID for a user by its value. Returns True if deleted."""
stmt = delete(UserHWID).where(UserHWID.user_id == user_id, UserHWID.hwid == hwid)
result = await db.execute(stmt)
await db.commit()
return result.rowcount > 0


async def reset_user_hwids(db: AsyncSession, user_id: int) -> int:
"""Delete all HWIDs for a user. Returns the number of HWIDs deleted."""
stmt = delete(UserHWID).where(UserHWID.user_id == user_id)
result = await db.execute(stmt)
await db.commit()
return result.rowcount
19 changes: 16 additions & 3 deletions app/db/crud/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,10 @@ async def create_user(db: AsyncSession, new_user: UserCreate, groups: list[Group
db_user.groups = groups
db_user.expire = new_user.expire or None
db_user.on_hold_timeout = new_user.on_hold_timeout or None

if new_user.hwid_limit is not None:
db_user.hwid_limit = new_user.hwid_limit

db_user.proxy_settings = new_user.proxy_settings.dict()

db.add(db_user)
Expand Down Expand Up @@ -821,6 +825,7 @@ async def create_users_bulk(
db_user.groups = list(groups)
db_user.expire = new_user.expire or None
db_user.on_hold_timeout = new_user.on_hold_timeout or None
db_user.hwid_limit = new_user.hwid_limit if new_user.hwid_limit is not None else None
db_user.proxy_settings = new_user.proxy_settings.dict()
db_users.append(db_user)

Expand Down Expand Up @@ -961,6 +966,9 @@ async def modify_user(
if modify.on_hold_expire_duration is not None:
db_user.on_hold_expire_duration = modify.on_hold_expire_duration

if modify.hwid_limit is not None:
db_user.hwid_limit = modify.hwid_limit
Comment on lines +969 to +970
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Allow explicit clearing of hwid_limit during user modify.

This branch only runs for non-None values, so clients cannot reset an existing override to NULL (inherit/default behavior).

🐛 Proposed fix
-    if modify.hwid_limit is not None:
-        db_user.hwid_limit = modify.hwid_limit
+    if "hwid_limit" in modify.model_fields_set:
+        db_user.hwid_limit = modify.hwid_limit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/db/crud/user.py` around lines 969 - 970, The modify logic for hwid_limit
only assigns when modify.hwid_limit is not None, preventing clients from
clearing an existing db_user.hwid_limit back to NULL; update the user-modify
handler (where modify.hwid_limit and db_user.hwid_limit are referenced) to
distinguish "no change" from "explicit clear" by adding an explicit flag or
using a sentinel (e.g., a separate boolean on the request or an Optional
wrapper) so that when the client signals clear you set db_user.hwid_limit =
None, otherwise leave it unchanged; locate the assignment involving
modify.hwid_limit and replace the conditional to apply the clear behavior when
the explicit-clear indicator is present.


if modify.next_plan is not None:
db_user.next_plan = NextPlan(
user_id=db_user.id,
Expand Down Expand Up @@ -1173,7 +1181,9 @@ async def bulk_revoke_user_sub(db: AsyncSession, users: list[User]) -> list[User
return users


async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None) -> None:
async def user_sub_update(
db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None, hwid: str | None = None
) -> None:
"""
Updates the user's subscription details.

Expand All @@ -1182,12 +1192,15 @@ async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: s
user_id (int): The user id whose subscription is to be updated.
user_agent (str): The user agent string.
ip (str | None): The client IP address.

hwid (str | None): The hardware ID of the client.
"""
# Clamp to column length; some clients send very long strings (e.g. encoded configs) as User-Agent.
sanitized_user_agent = (user_agent or "")[:_USER_AGENT_MAX_LEN]
sanitized_ip = (ip or "")[:_SUBSCRIPTION_UPDATE_IP_MAX_LEN] or None
agent = UserSubscriptionUpdate(user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip)
sanitized_hwid = (hwid or "")[:256] or None
agent = UserSubscriptionUpdate(
user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip, hwid=sanitized_hwid
)
db.add(agent)
await db.commit()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Add HWID support

Revision ID: f02194c811d6
Revises: 73c78c6a9b24
Create Date: 2026-05-14 14:23:22.927015

"""
from alembic import op
import sqlalchemy as sa
import app.db.compiles_types


# revision identifiers, used by Alembic.
revision = 'f02194c811d6'
down_revision = '73c78c6a9b24'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
'user_hwids',
sa.Column('id', app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False),
sa.Column('user_id', app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False),
sa.Column('hwid', sa.String(length=256), nullable=False),
sa.Column('device_os', sa.String(length=256), nullable=True),
sa.Column('os_version', sa.String(length=128), nullable=True),
sa.Column('device_model', sa.String(length=256), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_hwids_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_user_hwids')),
sa.UniqueConstraint('user_id', 'hwid', name=op.f('uq_user_hwids_user_id')),
)
with op.batch_alter_table('user_hwids', schema=None) as batch_op:
batch_op.create_index('ix_user_hwids_user_id', ['user_id'], unique=False)
batch_op.create_index('ix_user_hwids_hwid', ['hwid'], unique=False)
batch_op.create_index('ix_user_hwids_created_at', ['created_at'], unique=False)
batch_op.create_index('ix_user_hwids_last_used_at', ['last_used_at'], unique=False)

# Fixed MySQL JSON default: Add as nullable, update, then set NOT NULL
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.add_column(sa.Column('hwid', sa.JSON(), nullable=True))

op.execute("UPDATE settings SET hwid = '{}'")

with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.alter_column('hwid', type_=sa.JSON(), nullable=False)

with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
batch_op.add_column(sa.Column('hwid', sa.String(length=256), nullable=True))

with op.batch_alter_table('user_templates', schema=None) as batch_op:
batch_op.add_column(sa.Column('hwid_limit', sa.BigInteger(), nullable=True))

with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('hwid_limit', sa.BigInteger(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('hwid_limit')

with op.batch_alter_table('user_templates', schema=None) as batch_op:
batch_op.drop_column('hwid_limit')

with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
batch_op.drop_column('hwid')

with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.drop_column('hwid')

with op.batch_alter_table('user_hwids', schema=None) as batch_op:
batch_op.drop_index('ix_user_hwids_last_used_at')
batch_op.drop_index('ix_user_hwids_created_at')
batch_op.drop_index('ix_user_hwids_hwid')
batch_op.drop_index('ix_user_hwids_user_id')

op.drop_table('user_hwids')
32 changes: 31 additions & 1 deletion app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,11 @@ class User(Base):
next_plan: Mapped[Optional["NextPlan"]] = relationship(
uselist=False, back_populates="user", cascade="all, delete-orphan", init=False
)
hwids: Mapped[List["UserHWID"]] = relationship(back_populates="user", cascade="all, delete-orphan", init=False)
groups: Mapped[List["Group"]] = relationship(secondary=users_groups_association, back_populates="users", init=False)
proxy_settings: Mapped[Dict[str, Any]] = mapped_column(JSON(True), server_default=text("'{}'"), default=lambda: {})
proxy_settings: Mapped[Dict[str, Any]] = mapped_column(
JSON(True), server_default=text("'{}'"), default_factory=dict
)
status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.active)
used_traffic: Mapped[int] = mapped_column(BigInteger, default=0)
data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
Expand All @@ -179,6 +182,7 @@ class User(Base):
on_hold_expire_duration: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
on_hold_timeout: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
auto_delete_in_days: Mapped[Optional[int]] = mapped_column(default=None)
hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
edit_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)

Expand Down Expand Up @@ -332,6 +336,30 @@ class UserSubscriptionUpdate(Base):
created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
user_agent: Mapped[str] = mapped_column(String(512))
ip: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, default=None)
hwid: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, default=None)


class UserHWID(Base):
__tablename__ = "user_hwids"
__table_args__ = (
UniqueConstraint("user_id", "hwid"),
Index("ix_user_hwids_user_id", "user_id"),
Index("ix_user_hwids_hwid", "hwid"),
Index("ix_user_hwids_created_at", "created_at"),
Index("ix_user_hwids_last_used_at", "last_used_at"),
)

id: Mapped[int] = id_column()
user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE")
user: Mapped["User"] = relationship(back_populates="hwids", init=False)
hwid: Mapped[str] = mapped_column(String(256), nullable=False)
device_os: Mapped[Optional[str]] = mapped_column(String(256), default=None)
os_version: Mapped[Optional[str]] = mapped_column(String(128), default=None)
device_model: Mapped[Optional[str]] = mapped_column(String(256), default=None)
created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
last_used_at: Mapped[dt] = mapped_column(
DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False
)


template_group_association = Table(
Expand Down Expand Up @@ -378,6 +406,7 @@ class UserTemplate(Base):
)
groups: Mapped[List["Group"]] = relationship(secondary=template_group_association, back_populates="templates")
data_limit: Mapped[int] = mapped_column(BigInteger, default=0)
hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
expire_duration: Mapped[int] = mapped_column(BigInteger, default=0) # in seconds
on_hold_timeout: Mapped[Optional[int]] = mapped_column(default=None)
status: Mapped[UserStatusCreate] = mapped_column(SQLEnum(UserStatusCreate), default=UserStatusCreate.active)
Expand Down Expand Up @@ -790,4 +819,5 @@ class Settings(Base):
notification_settings: Mapped[dict] = mapped_column(JSON())
notification_enable: Mapped[dict] = mapped_column(JSON())
subscription: Mapped[dict] = mapped_column(JSON())
hwid: Mapped[dict] = mapped_column(JSON())
general: Mapped[dict] = mapped_column(JSON())
9 changes: 9 additions & 0 deletions app/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ def validate_recommended_apps(cls, v: list[Application]) -> list[Application]:
return v


class HWIDSettings(BaseModel):
enabled: bool = Field(default=False)
forced: bool = Field(default=False)
fallback_limit: int = Field(default=0, ge=0)
min_limit: int = Field(default=0, ge=0)
max_limit: int = Field(default=0, ge=0)


class General(BaseModel):
default_flow: XTLSFlows = Field(default=XTLSFlows.NONE)
default_method: ShadowsocksMethods = Field(default=ShadowsocksMethods.CHACHA20_POLY1305)
Expand All @@ -297,6 +305,7 @@ class SettingsSchema(BaseModel):
notification_settings: NotificationSettings | None = Field(default=None)
notification_enable: NotificationEnable | None = Field(default=None)
subscription: Subscription | None = Field(default=None)
hwid: HWIDSettings | None = Field(default=None)
general: General | None = Field(default=None)

model_config = ConfigDict(from_attributes=True)
9 changes: 9 additions & 0 deletions app/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,12 @@ def validate_datetimes(cls, value):
if not value:
return value
return fix_datetime_timezone(value)


class SubscriptionHeaders(BaseModel):
x_hwid: str | None = Field(default=None, alias="X-HWID")
x_device_os: str | None = Field(default=None, alias="X-Device-OS")
x_ver_os: str | None = Field(default=None, alias="X-Ver-OS")
x_device_model: str | None = Field(default=None, alias="X-Device-Model")

model_config = {"populate_by_name": True}
18 changes: 18 additions & 0 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class User(BaseModel):
on_hold_timeout: dt | int | None = Field(default=None)
group_ids: list[int] | None = Field(default_factory=list)
auto_delete_in_days: int | None = Field(default=None)
hwid_limit: int | None = Field(default=None)
next_plan: NextPlanModel | None = Field(default=None)


Expand Down Expand Up @@ -318,6 +319,7 @@ class UserSubscriptionUpdateSchema(BaseModel):
created_at: dt
user_agent: str
ip: str | None = Field(default=None)
hwid: str | None = Field(default=None)

model_config = ConfigDict(from_attributes=True)

Expand Down Expand Up @@ -345,6 +347,22 @@ class UserSubscriptionUpdateChart(BaseModel):
segments: list[UserSubscriptionUpdateChartSegment] = Field(default_factory=list)


class UserHWIDResponse(BaseModel):
id: int
hwid: str
device_os: str | None = None
os_version: str | None = None
device_model: str | None = None
created_at: dt
last_used_at: dt
model_config = ConfigDict(from_attributes=True)


class UserHWIDListResponse(BaseModel):
hwids: list[UserHWIDResponse]
count: int


class RemoveUsersResponse(BaseModel):
users: list[str]
count: int
Expand Down
1 change: 1 addition & 0 deletions app/models/user_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def dict(self, *, no_obj=True, **kwargs):
class UserTemplate(BaseModel):
name: str | None = None
data_limit: int | None = Field(ge=0, default=None, description="data_limit can be 0 or greater")
hwid_limit: int | None = Field(default=None)
expire_duration: int | None = Field(
ge=0, default=None, description="expire_duration can be 0 or greater in seconds"
)
Expand Down
25 changes: 25 additions & 0 deletions app/operation/hwid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from app.db import AsyncSession
from app.db.crud.hwid import delete_user_hwid, get_user_hwids, reset_user_hwids
from app.models.admin import AdminDetails
from app.models.user import UserHWIDListResponse, UserHWIDResponse
from app.operation import BaseOperation


class HWIDOperation(BaseOperation):
async def get_user_hwids(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> UserHWIDListResponse:
db_user = await self.get_validated_user_by_id(db, user_id, admin)
hwids = await get_user_hwids(db, db_user.id)
hwid_responses = [UserHWIDResponse.model_validate(h) for h in hwids]
return UserHWIDListResponse(hwids=hwid_responses, count=len(hwid_responses))

async def delete_user_hwid(self, db: AsyncSession, user_id: int, hwid: str, admin: AdminDetails) -> dict:
db_user = await self.get_validated_user_by_id(db, user_id, admin)
deleted = await delete_user_hwid(db, db_user.id, hwid)
if not deleted:
await self.raise_error(message="HWID not found", code=404)
return {}

async def reset_user_hwids(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> dict:
db_user = await self.get_validated_user_by_id(db, user_id, admin)
count = await reset_user_hwids(db, db_user.id)
return {"count": count}
Loading
Loading