Skip to content

Commit 20bc112

Browse files
committed
feat(subscriptions): add response headers configuration section
1 parent a12e44f commit 20bc112

8 files changed

Lines changed: 186 additions & 0 deletions

File tree

dashboard/public/statics/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@
239239
"randomizeOrder": "Randomize Subscription Order",
240240
"randomizeOrderDescription": "Shuffle configuration order on each subscription request"
241241
},
242+
"responseHeaders": {
243+
"title": "Response Headers",
244+
"description": "Custom HTTP headers to include in all subscription responses. Supports template variables.",
245+
"addHeader": "Add Header",
246+
"headerName": "Header name",
247+
"headerValue": "Header value",
248+
"noHeaders": "No response headers configured. Add headers to include custom HTTP headers in subscription responses."
249+
},
242250
"rules": {
243251
"title": "Subscription Rules",
244252
"description": "Configure client-specific subscription rules and formats",

dashboard/public/statics/locales/fa.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@
125125
"randomizeOrder": "تصادفی‌سازی ترتیب اشتراک",
126126
"randomizeOrderDescription": "در هر درخواست اشتراک، ترتیب کانفیگ‌ها به‌صورت تصادفی جابه‌جا می‌شود"
127127
},
128+
"responseHeaders": {
129+
"title": "هدرهای پاسخ",
130+
"description": "هدرهای HTTP سفارشی که در تمام پاسخ‌های اشتراک اضافه می‌شوند. از متغیرهای قالب پشتیبانی می‌کند.",
131+
"addHeader": "افزودن هدر",
132+
"headerName": "نام هدر",
133+
"headerValue": "مقدار هدر",
134+
"noHeaders": "هیچ هدر پاسخی پیکربندی نشده است. هدرها را اضافه کنید تا هدرهای HTTP سفارشی در پاسخ‌های اشتراک قرار گیرند."
135+
},
128136
"rules": {
129137
"title": "قوانین اشتراک",
130138
"description": "پیکربندی قوانین اشتراک مخصوص کلاینت و فرمت‌ها",

dashboard/public/statics/locales/ru.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,14 @@
257257
"randomizeOrder": "Случайный порядок конфигов",
258258
"randomizeOrderDescription": "Перемешивать порядок конфигураций при каждом запросе подписки"
259259
},
260+
"responseHeaders": {
261+
"title": "Заголовки ответа",
262+
"description": "Пользовательские HTTP-заголовки, добавляемые ко всем ответам подписки. Поддерживает шаблонные переменные.",
263+
"addHeader": "Добавить заголовок",
264+
"headerName": "Имя заголовка",
265+
"headerValue": "Значение заголовка",
266+
"noHeaders": "Заголовки ответа не настроены. Добавьте заголовки для включения пользовательских HTTP-заголовков в ответы подписки."
267+
},
260268
"rules": {
261269
"title": "Правила подписки",
262270
"description": "Настройка правил подписки для конкретных клиентов и форматов",

dashboard/public/statics/locales/zh.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@
223223
"randomizeOrder": "随机化订阅顺序",
224224
"randomizeOrderDescription": "每次请求订阅时,随机打乱配置顺序"
225225
},
226+
"responseHeaders": {
227+
"title": "响应头",
228+
"description": "在所有订阅响应中包含的自定义 HTTP 头。支持模板变量。",
229+
"addHeader": "添加头",
230+
"headerName": "头名称",
231+
"headerValue": "头值",
232+
"noHeaders": "未配置响应头。添加头以在订阅响应中包含自定义 HTTP 头。"
233+
},
226234
"rules": {
227235
"title": "订阅规则",
228236
"description": "为特定客户端模式定义自定义规则",
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { SubscriptionFormData } from '@/features/subscriptions/components/subscription-settings-schema'
2+
import { Button } from '@/components/ui/button'
3+
import { Input } from '@/components/ui/input'
4+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
5+
import { Textarea } from '@/components/ui/textarea'
6+
import { VariablesList } from '@/components/ui/variables-popover'
7+
import useDirDetection from '@/hooks/use-dir-detection'
8+
import { useIsMobile } from '@/hooks/use-mobile'
9+
import { Info, Plus, Trash2 } from 'lucide-react'
10+
import { UseFormReturn } from 'react-hook-form'
11+
import { useTranslation } from 'react-i18next'
12+
13+
export interface SubscriptionResponseHeadersSectionProps {
14+
form: UseFormReturn<SubscriptionFormData>
15+
}
16+
17+
export function SubscriptionResponseHeadersSection({ form }: SubscriptionResponseHeadersSectionProps) {
18+
const { t } = useTranslation()
19+
const dir = useDirDetection()
20+
const isMobile = useIsMobile()
21+
const infoPopoverSide = isMobile ? 'bottom' : dir === 'rtl' ? 'left' : 'right'
22+
const infoPopoverAlign = isMobile ? 'center' : 'start'
23+
24+
const responseHeaders = (form.watch('response_headers') || {}) as Record<string, string>
25+
const responseHeaderEntries = Object.entries(responseHeaders)
26+
const responseHeaderCount = responseHeaderEntries.length
27+
28+
const addResponseHeader = () => {
29+
const nextKey = `x-header-${Object.keys(responseHeaders).length + 1}`
30+
form.setValue(
31+
'response_headers',
32+
{
33+
...responseHeaders,
34+
[nextKey]: '',
35+
},
36+
{ shouldDirty: true },
37+
)
38+
}
39+
40+
const updateResponseHeaderName = (currentKey: string, nextKey: string) => {
41+
const updatedHeaders = Object.fromEntries(
42+
responseHeaderEntries.map(([headerKey, headerValue]) => [headerKey === currentKey ? nextKey : headerKey, headerValue]),
43+
)
44+
form.setValue('response_headers', updatedHeaders, { shouldDirty: true })
45+
}
46+
47+
const updateResponseHeaderValue = (headerKey: string, value: string) => {
48+
form.setValue(
49+
'response_headers',
50+
{
51+
...responseHeaders,
52+
[headerKey]: value,
53+
},
54+
{ shouldDirty: true },
55+
)
56+
}
57+
58+
const removeResponseHeader = (headerKey: string) => {
59+
const updatedHeaders = { ...responseHeaders }
60+
delete updatedHeaders[headerKey]
61+
form.setValue('response_headers', updatedHeaders, { shouldDirty: true })
62+
}
63+
64+
return (
65+
<div className="space-y-3">
66+
<div className="flex items-start justify-between gap-2">
67+
<div className="min-w-0 flex-1 space-y-2">
68+
<h3 className="text-base font-semibold sm:text-lg">{t('settings.subscriptions.responseHeaders.title')}</h3>
69+
<p className="text-xs text-muted-foreground sm:text-sm">{t('settings.subscriptions.responseHeaders.description')}</p>
70+
</div>
71+
<Popover>
72+
<PopoverTrigger asChild>
73+
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0">
74+
<Info className="h-4 w-4 text-muted-foreground" />
75+
</Button>
76+
</PopoverTrigger>
77+
<PopoverContent className="w-[min(90vw,20rem)] p-3 sm:w-80" side={infoPopoverSide} align={infoPopoverAlign} sideOffset={5}>
78+
<div className="space-y-1.5">
79+
<h4 className="mb-2 text-[11px] font-medium">{t('hostsDialog.variables.title')}</h4>
80+
<div className="max-h-[60vh] space-y-1 overflow-y-auto pr-1">
81+
<VariablesList includeProfileTitle={true} includeFormat={true} />
82+
</div>
83+
</div>
84+
</PopoverContent>
85+
</Popover>
86+
</div>
87+
88+
<div className="flex justify-end">
89+
<Button type="button" variant="outline" size="sm" onClick={addResponseHeader}>
90+
<Plus className="mr-1.5 h-3.5 w-3.5" />
91+
{t('settings.subscriptions.responseHeaders.addHeader')}
92+
</Button>
93+
</div>
94+
95+
<div className="space-y-3">
96+
{responseHeaderCount > 0 ? (
97+
responseHeaderEntries.map(([headerKey, headerValue], index) => (
98+
<div key={`sub-header-${index}`} className="space-y-2 rounded-lg border bg-card/50 p-3">
99+
<div className="flex items-start gap-2">
100+
<Input
101+
value={headerKey}
102+
onChange={e => updateResponseHeaderName(headerKey, e.target.value)}
103+
placeholder={t('settings.subscriptions.responseHeaders.headerName')}
104+
className="font-mono text-xs"
105+
/>
106+
<Button
107+
type="button"
108+
variant="ghost"
109+
size="icon"
110+
className="h-8 w-8 shrink-0 text-destructive hover:bg-destructive/10"
111+
onClick={() => removeResponseHeader(headerKey)}
112+
>
113+
<Trash2 className="h-4 w-4" />
114+
</Button>
115+
</div>
116+
<Textarea
117+
value={headerValue}
118+
onChange={e => updateResponseHeaderValue(headerKey, e.target.value)}
119+
placeholder={t('settings.subscriptions.responseHeaders.headerValue')}
120+
className="min-h-[60px] resize-none font-mono text-xs"
121+
rows={2}
122+
/>
123+
</div>
124+
))
125+
) : (
126+
<div className="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center">
127+
<p className="text-sm text-muted-foreground">{t('settings.subscriptions.responseHeaders.noHeaders')}</p>
128+
</div>
129+
)}
130+
</div>
131+
</div>
132+
)
133+
}

dashboard/src/features/subscriptions/components/subscription-settings-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const subscriptionSchema = z.object({
4242
allow_browser_config: z.boolean().optional(),
4343
disable_sub_template: z.boolean().optional(),
4444
randomize_order: z.boolean().optional(),
45+
response_headers: z.record(z.string()).optional(),
4546
rules: z.array(
4647
z.object({
4748
pattern: z.string().min(1, 'Pattern is required'),

dashboard/src/pages/_dashboard.settings.subscriptions.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SubscriptionApplicationsSection } from '@/features/subscriptions/compon
44
import { SubscriptionFormActions } from '@/features/subscriptions/components/subscription-form-actions'
55
import { SubscriptionGeneralSettingsSection } from '@/features/subscriptions/components/subscription-general-settings-section'
66
import { SubscriptionManualFormatsSection } from '@/features/subscriptions/components/subscription-manual-formats-section'
7+
import { SubscriptionResponseHeadersSection } from '@/features/subscriptions/components/subscription-response-headers-section'
78
import { SubscriptionRulesSection } from '@/features/subscriptions/components/subscription-rules-section'
89
import { SubscriptionSettingsSkeleton } from '@/features/subscriptions/components/subscription-settings-skeleton'
910
import {
@@ -41,6 +42,7 @@ export default function SubscriptionSettings() {
4142
allow_browser_config: true,
4243
disable_sub_template: false,
4344
randomize_order: false,
45+
response_headers: {},
4446
rules: [],
4547
applications: [],
4648
manual_sub_request: {
@@ -123,6 +125,9 @@ export default function SubscriptionSettings() {
123125
allow_browser_config: subscriptionData.allow_browser_config ?? true,
124126
disable_sub_template: subscriptionData.disable_sub_template ?? false,
125127
randomize_order: subscriptionData.randomize_order ?? false,
128+
response_headers: Object.fromEntries(
129+
Object.entries(subscriptionData.response_headers || {}).map(([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)]),
130+
),
126131
rules:
127132
subscriptionData.rules?.map((rule: ApiSubRule) => ({
128133
pattern: rule.pattern,
@@ -158,6 +163,12 @@ export default function SubscriptionSettings() {
158163
),
159164
}))
160165

166+
const processedResponseHeaders = Object.fromEntries(
167+
Object.entries(data.response_headers || {})
168+
.map(([key, value]) => [key.trim(), value.trim()] as const)
169+
.filter(([key, value]) => key && value),
170+
)
171+
161172
const rawApps = (data.applications || [])
162173
.map(app => ({
163174
name: app.name?.trim() || '',
@@ -195,6 +206,7 @@ export default function SubscriptionSettings() {
195206
profile_title: data.profile_title?.trim() || undefined,
196207
announce: data.announce?.trim() || undefined,
197208
announce_url: data.announce_url?.trim() || undefined,
209+
response_headers: processedResponseHeaders,
198210
rules: processedRules,
199211
applications: processedApplications,
200212
},
@@ -270,6 +282,9 @@ export default function SubscriptionSettings() {
270282
allow_browser_config: subscriptionData.allow_browser_config ?? true,
271283
disable_sub_template: subscriptionData.disable_sub_template ?? false,
272284
randomize_order: subscriptionData.randomize_order ?? false,
285+
response_headers: Object.fromEntries(
286+
Object.entries(subscriptionData.response_headers || {}).map(([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)]),
287+
),
273288
rules:
274289
subscriptionData.rules?.map((rule: ApiSubRule) => ({
275290
pattern: rule.pattern,
@@ -350,6 +365,10 @@ export default function SubscriptionSettings() {
350365

351366
<Separator className="my-3" />
352367

368+
<SubscriptionResponseHeadersSection form={form} />
369+
370+
<Separator className="my-3" />
371+
353372
<SubscriptionRulesSection
354373
form={form}
355374
ruleFields={ruleFields}

dashboard/src/service/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,7 @@ export interface Subscription {
13581358
/** @maxLength 128 */
13591359
announce?: string
13601360
announce_url?: string
1361+
response_headers?: Record<string, unknown>
13611362
rules: SubRule[]
13621363
manual_sub_request?: SubFormatEnable
13631364
applications?: Application[]

0 commit comments

Comments
 (0)