mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-22 20:46:08 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user