Browse Source

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 <[email protected]>
w3struk 18 hours ago
parent
commit
123fac222b

+ 33 - 2
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -1,9 +1,9 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd';
 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 { formatInboundLabel } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useDatepicker } from '@/hooks/useDatepicker';
@@ -64,6 +64,12 @@ const DEFAULT_SUB: SubSettings = {
   publicHost: '',
   publicHost: '',
 };
 };
 
 
+const SUBSCRIPTION_DOWNLOAD_NAMES = {
+  standard: 'subscription-standard.txt',
+  json: 'subscription-json.json',
+  clash: 'subscription-clash.yaml',
+} as const;
+
 export default function ClientInfoModal({
 export default function ClientInfoModal({
   open,
   open,
   client,
   client,
@@ -89,6 +95,7 @@ export default function ClientInfoModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const [downloadingFormat, setDownloadingFormat] = useState<keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!open) {
     if (!open) {
@@ -148,6 +155,21 @@ export default function ClientInfoModal({
     if (ok) messageApi.success(t('copied'));
     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() {
   async function loadIps() {
     if (!client?.email) return;
     if (!client?.email) return;
     setIpsLoading(true);
     setIpsLoading(true);
@@ -383,6 +405,9 @@ export default function ClientInfoModal({
                     <Tooltip title={t('copy')}>
                     <Tooltip title={t('copy')}>
                       <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
                       <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
                     </Tooltip>
                     </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
                     <Popover
                       trigger="click"
                       trigger="click"
                       placement="left"
                       placement="left"
@@ -411,6 +436,9 @@ export default function ClientInfoModal({
                       <Tooltip title={t('copy')}>
                       <Tooltip title={t('copy')}>
                         <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
                         <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
                       </Tooltip>
                       </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
                       <Popover
                         trigger="click"
                         trigger="click"
                         placement="left"
                         placement="left"
@@ -442,6 +470,9 @@ export default function ClientInfoModal({
                       <Tooltip title={t('copy')}>
                       <Tooltip title={t('copy')}>
                         <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
                         <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
                       </Tooltip>
                       </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
                       <Popover
                         trigger="click"
                         trigger="click"
                         placement="left"
                         placement="left"

+ 21 - 0
frontend/src/pages/sub/SubPage.tsx

@@ -24,6 +24,7 @@ import {
   AppleOutlined,
   AppleOutlined,
   CopyOutlined,
   CopyOutlined,
   DownOutlined,
   DownOutlined,
+  DownloadOutlined,
   MoonFilled,
   MoonFilled,
   MoonOutlined,
   MoonOutlined,
   QrcodeOutlined,
   QrcodeOutlined,
@@ -64,6 +65,8 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
 const datepicker = subData.datepicker || 'gregorian';
 const datepicker = subData.datepicker || 'gregorian';
 const announce = subData.announce || '';
 const announce = subData.announce || '';
 
 
+const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
+
 const isUnlimited = totalByte <= 0 && expireMs === 0;
 const isUnlimited = totalByte <= 0 && expireMs === 0;
 const isActive = (() => {
 const isActive = (() => {
   if (!enabled) return false;
   if (!enabled) return false;
@@ -354,6 +357,15 @@ export default function SubPage() {
                             {sId}
                             {sId}
                           </a>
                           </a>
                           <div className="sub-link-actions">
                           <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')} />
                             <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subJsonUrl)} aria-label={t('copy')} title={t('copy')} />
                             <Popover
                             <Popover
                               trigger="click"
                               trigger="click"
@@ -386,6 +398,15 @@ export default function SubPage() {
                             {sId}
                             {sId}
                           </a>
                           </a>
                           <div className="sub-link-actions">
                           <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')} />
                             <Button size="small" icon={<CopyOutlined />} onClick={() => copy(subClashUrl)} aria-label={t('copy')} title={t('copy')} />
                             <Popover
                             <Popover
                               trigger="click"
                               trigger="click"

+ 26 - 8
internal/sub/controller.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
+	"io/fs"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
 	"os"
 	"os"
@@ -340,11 +341,11 @@ func (a *SUBController) subs(c *gin.Context) {
 		logSubscriptionRoute(userAgent, "html")
 		logSubscriptionRoute(userAgent, "html")
 		return
 		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")
 		logSubscriptionRoute(userAgent, "clash")
 		return
 		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")
 		logSubscriptionRoute(userAgent, "json")
 		return
 		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 {
 	if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
 		body = diskBody
 		body = diskBody
 	} else {
 	} else {
-		readBody, err := distFS.ReadFile("dist/subpage.html")
+		readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
 		if err != nil {
 		if err != nil {
 			c.String(http.StatusInternalServerError, "missing embedded subpage")
 			c.String(http.StatusInternalServerError, "missing embedded subpage")
 			return
 			return
@@ -596,6 +597,12 @@ func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, er
 
 
 // subJsons handles HTTP requests for JSON subscription configurations.
 // subJsons handles HTTP requests for JSON subscription configurations.
 func (a *SUBController) subJsons(c *gin.Context) {
 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) {
 	if a.maybeServeSubPage(c) {
 		return
 		return
 	}
 	}
@@ -603,12 +610,12 @@ func (a *SUBController) subJsons(c *gin.Context) {
 }
 }
 
 
 func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
 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)
 		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")
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
 	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)
 		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)
 	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))
 	c.Data(200, contentType, []byte(jsonSub))
 	return true
 	return true
 }
 }
 
 
 func (a *SUBController) subClashs(c *gin.Context) {
 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) {
 	if a.maybeServeSubPage(c) {
 		return
 		return
 	}
 	}
-	if !a.serveClashBody(c) {
+	if !a.serveClashBody(c, false) {
 		writeSubError(c, nil)
 		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")
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)
 	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)
 		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)
 	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.
 		// 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)))
 		c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
 	}
 	}

+ 62 - 0
internal/sub/controller_test.go

@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
+	"testing/fstest"
 	"time"
 	"time"
 
 
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
@@ -19,6 +20,10 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"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
 // newTestSUBController builds a controller with just the bits loadSubTemplate
 // needs, so the template tests don't require a database.
 // needs, so the template tests don't require a database.
 func newTestSUBController() *SUBController {
 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) {
 func writeFile(t *testing.T, path, content string) {
 	t.Helper()
 	t.Helper()
 	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
 	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {

+ 4 - 4
internal/sub/dist.go

@@ -1,16 +1,16 @@
 package sub
 package sub
 
 
-import "embed"
+import "io/fs"
 
 
 // distFS holds the Vite-built frontend filesystem, injected from main at
 // distFS holds the Vite-built frontend filesystem, injected from main at
 // startup. The `web` package owns the //go:embed directive (because dist/
 // 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
 // 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
 // doesn't import web — that would create an import cycle once any
 // internal/web/controller handler reuses sub's link-building service.
 // 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
 // SetDistFS installs the embedded frontend filesystem the sub server uses
 // for its info page assets. Must be called before NewServer().Start().
 // 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
 }
 }