Skip to content

Commit 94502dc

Browse files
feat(subscription): add IP address support to user subscription updates
1 parent 7aff141 commit 94502dc

7 files changed

Lines changed: 73 additions & 7 deletions

File tree

app/db/crud/user.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .group import get_groups_by_ids
4040

4141
_USER_AGENT_MAX_LEN = UserSubscriptionUpdate.__table__.columns.user_agent.type.length or 512
42+
_SUBSCRIPTION_UPDATE_IP_MAX_LEN = UserSubscriptionUpdate.__table__.columns.ip.type.length or 64
4243

4344

4445
def _build_user_select_stmt(
@@ -1140,19 +1141,21 @@ async def bulk_revoke_user_sub(db: AsyncSession, users: list[User]) -> list[User
11401141
return users
11411142

11421143

1143-
async def user_sub_update(db: AsyncSession, user_id: User, user_agent: str) -> User:
1144+
async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None) -> None:
11441145
"""
11451146
Updates the user's subscription details.
11461147
11471148
Args:
11481149
db (AsyncSession): Database session.
1149-
user_id (User): The user id whose subscription is to be updated.
1150+
user_id (int): The user id whose subscription is to be updated.
11501151
user_agent (str): The user agent string.
1152+
ip (str | None): The client IP address.
11511153
11521154
"""
11531155
# Clamp to column length; some clients send very long strings (e.g. encoded configs) as User-Agent.
11541156
sanitized_user_agent = (user_agent or "")[:_USER_AGENT_MAX_LEN]
1155-
agent = UserSubscriptionUpdate(user_id=user_id, user_agent=sanitized_user_agent)
1157+
sanitized_ip = (ip or "")[:_SUBSCRIPTION_UPDATE_IP_MAX_LEN] or None
1158+
agent = UserSubscriptionUpdate(user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip)
11561159
db.add(agent)
11571160
await db.commit()
11581161

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""add ip to user subscription updates
2+
3+
Revision ID: 73c78c6a9b24
4+
Revises: af2d644dda44
5+
Create Date: 2026-05-06 00:00:00.000000
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
revision = "73c78c6a9b24"
14+
down_revision = "af2d644dda44"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column("user_subscription_updates", sa.Column("ip", sa.String(length=64), nullable=True))
21+
22+
23+
def downgrade() -> None:
24+
with op.batch_alter_table("user_subscription_updates", schema=None) as batch_op:
25+
batch_op.drop_column("ip")

app/db/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ class UserSubscriptionUpdate(Base):
331331
user: Mapped["User"] = relationship(back_populates="subscription_updates", init=False)
332332
created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
333333
user_agent: Mapped[str] = mapped_column(String(512))
334+
ip: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, default=None)
334335

335336

336337
template_group_association = Table(

app/models/user.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ class SubscriptionUserResponse(UserResponse):
132132
note: str | None = Field(None, exclude=True)
133133
auto_delete_in_days: int | None = Field(None, exclude=True)
134134
subscription_url: str | None = Field(None, exclude=True)
135+
ip: str | None = Field(default=None)
135136
model_config = ConfigDict(from_attributes=True)
136137

137138

@@ -164,6 +165,7 @@ class UsersSimpleResponse(BaseModel):
164165
class UserSubscriptionUpdateSchema(BaseModel):
165166
created_at: dt
166167
user_agent: str
168+
ip: str | None = Field(default=None)
167169

168170
model_config = ConfigDict(from_attributes=True)
169171

app/operation/subscription.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ async def user_subscription(
253253
token: str,
254254
accept_header: str = "",
255255
user_agent: str = "",
256+
ip: str | None = None,
256257
request_url: str = "",
257258
):
258259
"""
@@ -301,7 +302,7 @@ async def user_subscription(
301302
await self.raise_error(message="Client not supported", code=406)
302303

303304
# Update user subscription info
304-
await user_sub_update(db, db_user.id, user_agent)
305+
await user_sub_update(db, db_user.id, user_agent, ip=ip)
305306
conf, media_type = await self.fetch_config(user, client_type)
306307

307308
# If disable_sub_template is True and it's a browser request, use inline to view instead of download
@@ -397,7 +398,9 @@ async def user_subscription_by_id(
397398
db_user = await self.get_validated_user_by_id(db, user_id, admin)
398399
return await self.user_subscription_by_user(db_user, client_type, request_url)
399400

400-
async def user_subscription_info(self, db: AsyncSession, token: str) -> tuple[SubscriptionUserResponse, dict]:
401+
async def user_subscription_info(
402+
self, db: AsyncSession, token: str, ip: str | None = None
403+
) -> tuple[SubscriptionUserResponse, dict]:
401404
"""Retrieves detailed information about the user's subscription."""
402405
sub_settings: SubSettings = await subscription_settings()
403406
db_user = await self.get_validated_sub(db, token=token)
@@ -409,6 +412,7 @@ async def user_subscription_info(self, db: AsyncSession, token: str) -> tuple[Su
409412
except ValueError as exc:
410413
await self.raise_error(message=str(exc), code=400)
411414
user_response = SubscriptionUserResponse.model_validate(db_user)
415+
user_response.ip = ip
412416

413417
return user_response, response_headers
414418

app/routers/subscription.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
subscription_operator = SubscriptionOperation(operator_type=OperatorType.API)
1616

1717

18+
def get_client_ip(request: Request) -> str | None:
19+
forwarded_for = request.headers.get("X-Forwarded-For")
20+
if forwarded_for:
21+
return forwarded_for.split(",")[0].strip()
22+
if request.client:
23+
return request.client.host
24+
return None
25+
26+
1827
@router.get("/{token}/")
1928
@router.get("/{token}", include_in_schema=False)
2029
async def user_subscription(
@@ -29,14 +38,17 @@ async def user_subscription(
2938
token=token,
3039
accept_header=request.headers.get("Accept", ""),
3140
user_agent=user_agent,
41+
ip=get_client_ip(request),
3242
request_url=str(request.url),
3343
)
3444

3545

3646
@router.get("/{token}/info", response_model=SubscriptionUserResponse)
3747
async def user_subscription_info(request: Request, token: str, db: AsyncSession = Depends(get_db)):
3848
"""Retrieves detailed information about the user's subscription."""
39-
user_data, response_headers = await subscription_operator.user_subscription_info(db, token=token)
49+
user_data, response_headers = await subscription_operator.user_subscription_info(
50+
db, token=token, ip=get_client_ip(request)
51+
)
4052
return JSONResponse(content=user_data.model_dump(mode="json"), headers=response_headers)
4153

4254

tests/api/test_user.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,32 @@ def test_user_sub_update_user_agent(access_token):
308308
try:
309309
url = user["subscription_url"]
310310
user_agent = "v2rayNG/1.9.46 This is PasarGuard Test"
311-
client.get(url, headers={"User-Agent": user_agent})
311+
ip = "203.0.113.10"
312+
client.get(url, headers={"User-Agent": user_agent, "X-Forwarded-For": ip})
312313
response = client.get(
313314
f"/api/user/{user['username']}/sub_update",
314315
headers={"Authorization": f"Bearer {access_token}"},
315316
)
316317
assert response.status_code == status.HTTP_200_OK
317318
assert response.json()["updates"][0]["user_agent"] == user_agent
319+
assert response.json()["updates"][0]["ip"] == ip
320+
finally:
321+
delete_user(access_token, user["username"])
322+
cleanup_groups(access_token, core, groups)
323+
324+
325+
def test_user_subscription_info_returns_request_ip(access_token):
326+
core, groups = setup_groups(access_token, 1)
327+
user = create_user(
328+
access_token,
329+
group_ids=[groups[0]["id"]],
330+
payload={"username": unique_name("test_subscription_info_ip")},
331+
)
332+
try:
333+
ip = "198.51.100.7"
334+
response = client.get(f"{user['subscription_url']}/info", headers={"X-Forwarded-For": ip})
335+
assert response.status_code == status.HTTP_200_OK
336+
assert response.json()["ip"] == ip
318337
finally:
319338
delete_user(access_token, user["username"])
320339
cleanup_groups(access_token, core, groups)

0 commit comments

Comments
 (0)