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
2 changes: 2 additions & 0 deletions app/models/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re
from enum import Enum, StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

Expand Down Expand Up @@ -197,6 +198,7 @@ class ConfigFormat(str, Enum):
class SubRule(BaseModel):
pattern: str
target: ConfigFormat
response_headers: dict[str, Any] = Field(default_factory=dict)


class SubFormatEnable(BaseModel):
Expand Down
74 changes: 70 additions & 4 deletions app/operation/subscription.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import re
from json import dumps as json_dumps
from datetime import datetime as dt
from typing import Any

from fastapi import Response
from fastapi.responses import HTMLResponse
Expand Down Expand Up @@ -30,6 +32,8 @@


class SubscriptionOperation(BaseOperation):
_ENCODED_RULE_RESPONSE_HEADERS = {"announce", "profile-title"}

@staticmethod
async def validated_user(db_user: User) -> UsersResponseWithInbounds:
user = UsersResponseWithInbounds.model_validate(db_user.__dict__)
Expand All @@ -46,6 +50,14 @@ async def detect_client_type(user_agent: str, rules: list[SubRule]) -> ConfigFor
if re.match(rule.pattern, user_agent):
return rule.target

@staticmethod
def detect_client_rule(user_agent: str, rules: list[SubRule]) -> SubRule | None:
"""Return the first matching subscription rule for the provided user agent."""
for rule in rules:
if re.match(rule.pattern, user_agent):
return rule
return None

@staticmethod
def _format_profile_title(
user: UsersResponseWithInbounds, format_variables: dict, sub_settings: SubSettings
Expand All @@ -67,7 +79,11 @@ def _format_profile_title(

@staticmethod
def create_response_headers(
user: UsersResponseWithInbounds, request_url: str, sub_settings: SubSettings, inline: bool = False
user: UsersResponseWithInbounds,
request_url: str,
sub_settings: SubSettings,
inline: bool = False,
extra_headers: dict[str, str] | None = None,
) -> dict:
"""Create response headers for subscription responses, including user subscription info."""
# Generate user subscription info
Expand All @@ -89,7 +105,7 @@ def create_response_headers(
# Use 'inline' for browser viewing, 'attachment' for download
disposition = "inline" if inline else "attachment"

return {
headers = {
"content-disposition": f'{disposition}; filename="{user.username}"',
"profile-web-page-url": request_url,
"support-url": support_url,
Expand All @@ -99,6 +115,49 @@ def create_response_headers(
"announce": encode_title(sub_settings.announce),
"announce-url": sub_settings.announce_url,
}
if extra_headers:
headers.update(extra_headers)
return headers

@classmethod
def _format_rule_response_headers(
cls, rule: SubRule | None, format_variables: dict[str, str | int | float]
) -> dict[str, str]:
if not rule or not rule.response_headers:
return {}

headers: dict[str, str] = {}
for raw_name, raw_value in rule.response_headers.items():
header_name = str(raw_name).strip()
if not header_name or raw_value is None:
continue

formatted_value = cls._stringify_rule_header_value(raw_value, format_variables)
if not formatted_value:
continue

if header_name.lower() in cls._ENCODED_RULE_RESPONSE_HEADERS:
formatted_value = encode_title(formatted_value)

headers[header_name] = formatted_value

return headers

@staticmethod
def _stringify_rule_header_value(value: Any, format_variables: dict[str, str | int | float]) -> str:
if isinstance(value, str):
header_value = value.strip()
if not header_value:
return ""
try:
return header_value.format_map(format_variables)
except (ValueError, KeyError):
return header_value

if isinstance(value, (dict, list, tuple, bool, int, float)):
return json_dumps(value, ensure_ascii=False, separators=(",", ":"))

return str(value).strip()

@staticmethod
def create_info_response_headers(user: UsersResponseWithInbounds, sub_settings: SubSettings) -> dict:
Expand Down Expand Up @@ -181,7 +240,8 @@ async def user_subscription(
)
)
else:
client_type = await self.detect_client_type(user_agent, sub_settings.rules)
matched_rule = self.detect_client_rule(user_agent, sub_settings.rules)
client_type = matched_rule.target if matched_rule else None
if client_type == ConfigFormat.block or not client_type:
await self.raise_error(message="Client not supported", code=406)

Expand All @@ -191,7 +251,13 @@ async def user_subscription(

# If disable_sub_template is True and it's a browser request, use inline to view instead of download
inline_view = sub_settings.disable_sub_template and is_browser_request
response_headers = self.create_response_headers(user, request_url, sub_settings, inline=inline_view)
response_headers = self.create_response_headers(
user,
request_url,
sub_settings,
inline=inline_view,
extra_headers=self._format_rule_response_headers(matched_rule, setup_format_variables(user)),
)

# Create response with appropriate headers
return Response(content=conf, media_type=media_type, headers=response_headers)
Expand Down
5 changes: 5 additions & 0 deletions dashboard/public/statics/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@
"patternDescription": "Pattern to match against client requests",
"target": "Target Format",
"targetDescription": "Configuration format to serve for this pattern",
"responseHeaders": "Response Headers",
"responseHeadersDescription": "Optional headers to include when this rule matches.",
"addHeader": "Add Header",
"headerName": "Header name",
"headerValue": "Header value",
"noRules": "No rules configured. Add rules to customize subscription behavior for different clients."
},
"formats": {
Expand Down
5 changes: 5 additions & 0 deletions dashboard/public/statics/locales/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@
"patternDescription": "الگو برای تطبیق با درخواست‌های کلاینت",
"target": "فرمت هدف",
"targetDescription": "فرمت پیکربندی برای ارائه این الگو",
"responseHeaders": "هدرهای پاسخ",
"responseHeadersDescription": "هدرهای اختیاری که هنگام تطبیق این قانون به پاسخ اضافه می‌شوند.",
"addHeader": "افزودن هدر",
"headerName": "نام هدر",
"headerValue": "مقدار هدر",
"noRules": "هیچ قانونی پیکربندی نشده است. قوانین را اضافه کنید تا رفتار اشتراک را برای کلاینت‌های مختلف سفارشی کنید."
},
"formats": {
Expand Down
5 changes: 5 additions & 0 deletions dashboard/public/statics/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@
"patternDescription": "Шаблон для сопоставления с запросами клиентов",
"target": "Целевой формат",
"targetDescription": "Формат конфигурации для обслуживания этого шаблона",
"responseHeaders": "Заголовки ответа",
"responseHeadersDescription": "Дополнительные заголовки, которые добавляются при срабатывании этого правила.",
"addHeader": "Добавить заголовок",
"headerName": "Имя заголовка",
"headerValue": "Значение заголовка",
"noRules": "Правила не настроены. Добавьте правила для настройки поведения подписки для разных клиентов."
},
"formats": {
Expand Down
7 changes: 6 additions & 1 deletion dashboard/public/statics/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,12 @@
"patternPlaceholder": "输入客户端模式(例如:*android*)",
"patternDescription": "匹配客户端用户代理的模式",
"target": "目标格式",
"targetDescription": "匹配此模式时使用的配置格式"
"targetDescription": "匹配此模式时使用的配置格式",
"responseHeaders": "响应头",
"responseHeadersDescription": "当该规则匹配时附加到响应的可选头。",
"addHeader": "添加头",
"headerName": "头名称",
"headerValue": "头值"
},
"formats": {
"title": "手动订阅格式",
Expand Down
Loading
Loading