feat(sub): add raw subscription download actions (#6017)

* feat(sub): add raw subscription downloads

* fix(sub): address review feedback

* feat(sub): add download buttons to client subscriptions

* fix(sub): fetch subscription before download

---------

Co-authored-by: w3struk <w3struk@gmail.com>
This commit is contained in:
w3struk
2026-07-21 18:52:33 +05:00
committed by GitHub
parent d38c912dc1
commit 123fac222b
5 changed files with 146 additions and 14 deletions
+33 -2
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { useDatepicker } from '@/hooks/useDatepicker';
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
publicHost: '',
};
const SUBSCRIPTION_DOWNLOAD_NAMES = {
standard: 'subscription-standard.txt',
json: 'subscription-json.json',
clash: 'subscription-clash.yaml',
} as const;
export default function ClientInfoModal({
open,
client,
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
useEffect(() => {
if (!open) {
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
if (ok) messageApi.success(t('copied'));
}
async function downloadSubscription(url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES) {
if (!url || downloadingFormat) return;
setDownloadingFormat(format);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Subscription download failed');
const content = await response.text();
FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]);
} catch (_) {
messageApi.error(t('somethingWentWrong'));
} finally {
setDownloadingFormat(null);
}
}
async function loadIps() {
if (!client?.email) return;
setIpsLoading(true);
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'standard'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subLink, 'standard')} />
</Tooltip>
<Popover
trigger="click"
placement="left"
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'json'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subJsonLink, 'json')} />
</Tooltip>
<Popover
trigger="click"
placement="left"
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} loading={downloadingFormat === 'clash'} disabled={downloadingFormat !== null} onClick={() => void downloadSubscription(subClashLink, 'clash')} />
</Tooltip>
<Popover
trigger="click"
placement="left"
+21
View File
@@ -24,6 +24,7 @@ import {
AppleOutlined,
CopyOutlined,
DownOutlined,
DownloadOutlined,
MoonFilled,
MoonOutlined,
QrcodeOutlined,
@@ -64,6 +65,8 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
const datepicker = subData.datepicker || 'gregorian';
const announce = subData.announce || '';
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
const isUnlimited = totalByte <= 0 && expireMs === 0;
const isActive = (() => {
if (!enabled) return false;
@@ -354,6 +357,15 @@ export default function SubPage() {
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subJsonUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
<Popover
trigger="click"
@@ -386,6 +398,15 @@ export default function SubPage() {
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subClashUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
<Popover
trigger="click"
+26 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
@@ -340,11 +341,11 @@ func (a *SUBController) subs(c *gin.Context) {
logSubscriptionRoute(userAgent, "html")
return
}
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c) {
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
logSubscriptionRoute(userAgent, "clash")
return
}
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8") {
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
logSubscriptionRoute(userAgent, "json")
return
}
@@ -446,7 +447,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
body = diskBody
} else {
readBody, err := distFS.ReadFile("dist/subpage.html")
readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded subpage")
return
@@ -596,6 +597,12 @@ func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, er
// subJsons handles HTTP requests for JSON subscription configurations.
func (a *SUBController) subJsons(c *gin.Context) {
if strings.EqualFold(c.Query("view"), "raw") {
if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
writeSubError(c, nil)
}
return
}
if a.maybeServeSubPage(c) {
return
}
@@ -603,12 +610,12 @@ func (a *SUBController) subJsons(c *gin.Context) {
}
func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
if !a.serveJsonBody(c, alwaysReturnArray, contentType) {
if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
writeSubError(c, nil)
}
}
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string) bool {
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
@@ -624,21 +631,30 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
}
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
}
c.Data(200, contentType, []byte(jsonSub))
return true
}
func (a *SUBController) subClashs(c *gin.Context) {
if strings.EqualFold(c.Query("view"), "raw") {
if !a.serveClashBody(c, true) {
writeSubError(c, nil)
}
return
}
if a.maybeServeSubPage(c) {
return
}
if !a.serveClashBody(c) {
if !a.serveClashBody(c, false) {
writeSubError(c, nil)
}
}
func (a *SUBController) serveClashBody(c *gin.Context) bool {
func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
clashSub, header, err := a.subClashService.GetClash(subId, host)
@@ -654,7 +670,9 @@ func (a *SUBController) serveClashBody(c *gin.Context) bool {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
}
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subTitle != "" {
if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
} else if a.subTitle != "" {
// Clash clients commonly use Content-Disposition to choose the imported profile name.
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
}
+62
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/gin-gonic/gin"
@@ -19,6 +20,10 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
var testDistFS = fstest.MapFS{
"dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
}
// newTestSUBController builds a controller with just the bits loadSubTemplate
// needs, so the template tests don't require a database.
func newTestSUBController() *SUBController {
@@ -391,6 +396,63 @@ func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
})
}
func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
oldDistFS := distFS
distFS = testDistFS
t.Cleanup(func() { distFS = oldDistFS })
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
tests := []struct {
name string
path string
contentType string
disposition string
bodyContains string
}{
{name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
{name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
req.Header.Set("Accept", "text/html")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != tt.contentType {
t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
}
if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
}
if !strings.Contains(resp.Body.String(), tt.bodyContains) {
t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
}
})
}
for _, path := range []string{"/json/s1", "/clash/s1"} {
t.Run(path+" browser page", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
req.Header.Set("Accept", "text/html")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("Content-Type = %q, want HTML", got)
}
})
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+4 -4
View File
@@ -1,16 +1,16 @@
package sub
import "embed"
import "io/fs"
// distFS holds the Vite-built frontend filesystem, injected from main at
// startup. The `web` package owns the //go:embed directive (because dist/
// is at internal/web/dist/), and hands the FS over via SetDistFS so the sub package
// doesn't import web — that would create an import cycle once any
// internal/web/controller handler reuses sub's link-building service.
var distFS embed.FS
var distFS fs.FS
// SetDistFS installs the embedded frontend filesystem the sub server uses
// for its info page assets. Must be called before NewServer().Start().
func SetDistFS(fs embed.FS) {
distFS = fs
func SetDistFS(frontendFS fs.FS) {
distFS = frontendFS
}