Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cd6dd0f
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Mar 20, 2026
1ea9b4d
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Mar 21, 2026
947306b
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Mar 22, 2026
2b441df
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Mar 23, 2026
62388dd
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Mar 24, 2026
54ad1a1
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Mar 25, 2026
2244266
Merge branch 'imzyb:main' into main
mi0e Mar 31, 2026
6aeb285
Merge branch 'imzyb:main' into main
mi0e Apr 13, 2026
2f127c4
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 13, 2026
f460ce3
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 13, 2026
dbbc631
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 13, 2026
e555cd6
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 13, 2026
b704d21
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
48f7492
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
a15c9b2
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
a879462
Merge branch 'imzyb:main' into main
mi0e Apr 14, 2026
5923e0f
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
b3ad78b
Merge branch 'imzyb:main' into main
mi0e Apr 14, 2026
8032ecf
Merge branch 'imzyb:main' into main
mi0e Apr 14, 2026
348b40b
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
3d8c69c
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
295e2f0
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 14, 2026
9679da9
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 15, 2026
82aa9ad
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 17, 2026
5157eaf
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 17, 2026
f08d7fd
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 17, 2026
027d93e
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 17, 2026
d5ce437
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 17, 2026
dfae5b7
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 18, 2026
98432b9
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 18, 2026
566eb88
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 18, 2026
0e08a42
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 18, 2026
468bc76
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 19, 2026
cb5ba5b
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 20, 2026
28e3624
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 20, 2026
e9b40ea
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 20, 2026
4a215ca
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 23, 2026
8c10034
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 24, 2026
e8f36ae
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 24, 2026
cdf3924
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 24, 2026
53ed554
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 25, 2026
3e44470
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 25, 2026
126974a
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 25, 2026
587782c
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 25, 2026
8030c9c
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 27, 2026
139ee0a
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Apr 27, 2026
88331bf
Merge branch 'imzyb:main' into main
mi0e Jul 10, 2026
c4323e0
fix: use base64 source URL for external converters
mi0e Jul 11, 2026
7e26a95
fix: use base64 source URLs for external converters
mi0e Jul 11, 2026
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
53 changes: 7 additions & 46 deletions functions/modules/subscription/main-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { sendEnhancedTgNotification, tgEscape } from '../notifications.js';
import { KV_KEY_SUBS, KV_KEY_PROFILES, KV_KEY_SETTINGS, DEFAULT_SETTINGS as defaultSettings, DEFAULT_SUBCONVERTER_BACKEND } from '../config.js';
import { createDisguiseResponse } from '../disguise-page.js';
import { generateCacheKey, setCache } from '../../services/node-cache-service.js';
import { countNodeLines, prepareExternalNodesCallback, shouldUseExternalNodesCallback } from '../../services/external-nodes-callback-service.js';
import { resolveRequestContext } from './request-context.js';
import { resolveNodeListWithCache } from './cache-manager.js';
import { ProcessorService } from '../../services/processor-service.js';
Expand Down Expand Up @@ -242,7 +241,6 @@ export function normalizeSubconverterBackend(input, fallback = DEFAULT_SUBCONVER
export function buildExternalSubconverterUrl({
backend,
targetFormat,
nodeList,
requestUrl,
profileSub = {},
globalSub = {},
Expand All @@ -262,19 +260,12 @@ export function buildExternalSubconverterUrl({
if (k && v) externalUrl.searchParams.set(k, v);
});

const normalizedNodeList = String(nodeList || '').trim();
if (normalizedNodeList) {
// 关键:第三方转换模式应接收 MiSub 已完成预处理后的节点列表。
// subconverter 对多个内联节点更稳定的分隔符是 `|`;保留换行会让部分后端只解析首行或直接报 No nodes were found。
const inlineNodeList = normalizedNodeList.split(/\r?\n+/).map(line => line.trim()).filter(Boolean).join('|');
externalUrl.searchParams.set('url', inlineNodeList);
} else if (requestUrl) {
// 兜底保留旧回调 URL 逻辑,避免异常空列表场景构造无效 converter 请求。
if (requestUrl) {
// 第三方后端通过稳定的 Base64 订阅地址拉取节点,避免将完整节点列表内联到重定向 URL。
// 一次性替换查询参数也能防止转换参数递归传入数据源请求。
const dataSourceUrl = new URL(requestUrl);
const paramsToClear = ['target', 'engine', 'builtin', 'clash', 'singbox', 'surge', 'loon', 'quanx', 'egern', 'base64', 'v2ray', 'trojan', 'list', 'include', 'exclude'];
paramsToClear.forEach(p => dataSourceUrl.searchParams.delete(p));
dataSourceUrl.searchParams.set('target', 'nodes');
dataSourceUrl.searchParams.set('builtin', 'true');
dataSourceUrl.search = '?base64&callback_token=external';
dataSourceUrl.hash = '';
externalUrl.searchParams.set('url', dataSourceUrl.toString());
}

Expand Down Expand Up @@ -810,11 +801,9 @@ export async function handleMisubRequest(context) {
}

const backend = url.searchParams.get('backend') || profileSub.backend || globalSub.defaultBackend;
const externalNodeList = isProfileExpired ? (DEFAULT_EXPIRED_NODE + '\n') : combinedNodeList;
let externalUrl = buildExternalSubconverterUrl({
const externalUrl = buildExternalSubconverterUrl({
backend,
targetFormat,
nodeList: externalNodeList,
requestUrl: request.url,
profileSub,
globalSub,
Expand All @@ -823,34 +812,6 @@ export async function handleMisubRequest(context) {
templateSource,
subName
});
let externalRedirectMode = 'external-redirect-v2';

if (shouldUseExternalNodesCallback({
inlineUrlLength: externalUrl.toString().length,
nodeCount: countNodeLines(externalNodeList)
})) {
const callbackProfileId = profileIdentifier || token || 'default';
const callback = await prepareExternalNodesCallback({
env,
requestUrl: request.url,
profileId: callbackProfileId,
nodesText: externalNodeList,
encoding: 'base64'
});
externalUrl = buildExternalSubconverterUrl({
backend,
targetFormat,
nodeList: callback.callbackUrl,
requestUrl: request.url,
profileSub,
globalSub,
userAgent: userAgentHeader,
searchParams: url.searchParams,
templateSource,
subName
});
externalRedirectMode = 'external-redirect-callback';
}

// [Access Log] Send notification for external redirection
if (!url.searchParams.has('callback_token') && !shouldSkipLogging && config.enableAccessLog) {
Expand All @@ -874,7 +835,7 @@ export async function handleMisubRequest(context) {
headers: {
'Location': externalUrl.toString(),
'Cache-Control': 'no-store, no-cache',
'X-MiSub-Mode': externalRedirectMode,
'X-MiSub-Mode': 'external-redirect-v2',
...(templateSource.kind === 'builtin' ? { 'X-MiSub-Template-Warning': 'external-engine-ignores-builtin-template' } : {})
}
});
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/main-handler-template-url.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,25 +64,27 @@ describe('Main handler template url', () => {
const externalUrl = buildExternalSubconverterUrl({
backend: 'https://sub.example.com/sub',
targetFormat: 'clash',
nodeList: 'ss://node-a#A\nss://node-b#B',
requestUrl: 'https://misub.example/profile-token/profile-id?target=clash&refresh=1',
templateSource: resolveTemplateSource('https://raw.githubusercontent.com/Luckylos/shellcrashyaml/main/subconverter-shellcrash-needs.yaml'),
subName: 'ShellCrash'
});

expect(externalUrl.searchParams.get('target')).toBe('clash');
expect(externalUrl.searchParams.get('url')).toBe('ss://node-a#A|ss://node-b#B');
expect(externalUrl.searchParams.get('url')).toBe('https://misub.example/profile-token/profile-id?base64&callback_token=external');
expect(externalUrl.toString()).toContain('url=https%3A%2F%2Fmisub.example%2Fprofile-token%2Fprofile-id%3Fbase64%26callback_token%3Dexternal');
expect(externalUrl.searchParams.get('config')).toBe('https://raw.githubusercontent.com/Luckylos/shellcrashyaml/main/subconverter-shellcrash-needs.yaml');
});

it('should normalize GitHub blob config URLs before forwarding to external subconverter backends', () => {
const externalUrl = buildExternalSubconverterUrl({
backend: 'https://sub.example.com/sub',
targetFormat: 'clash',
nodeList: 'ss://node-a#A',
requestUrl: 'https://misub.example/stable-token?clash&nocache=1',
templateSource: resolveTemplateSource('https://github.com/Luckylos/shellcrashyaml/blob/main/subconverter-shellcrash-needs.yaml'),
subName: 'ShellCrash'
});

expect(externalUrl.searchParams.get('url')).toBe('https://misub.example/stable-token?base64&callback_token=external');
expect(externalUrl.searchParams.get('config')).toBe('https://raw.githubusercontent.com/Luckylos/shellcrashyaml/main/subconverter-shellcrash-needs.yaml');
});

Expand Down
59 changes: 25 additions & 34 deletions tests/unit/misub-request-regression.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('handleMisubRequest regression coverage', () => {
}
});

it('normalizes external converter hosts and sends preprocessed nodes inline', async () => {
it('normalizes external converter hosts and forwards the Base64 subscription URL', async () => {
const subscriptions = [{
id: 'sub-a',
name: '鏈哄満A',
Expand Down Expand Up @@ -122,23 +122,33 @@ describe('handleMisubRequest regression coverage', () => {
waitUntil: vi.fn()
});
const redirectUrl = new URL(initialResponse.headers.get('Location'));
const inlineNodeList = redirectUrl.searchParams.get('url');
const dataSourceUrl = redirectUrl.searchParams.get('url');

expect(initialResponse.status).toBe(302);
expect(redirectUrl.origin + redirectUrl.pathname).toBe('https://sub.example/sub');
expect(redirectUrl.searchParams.get('target')).toBe('clash');
expect(inlineNodeList).toContain('trojan://pass@example.com:443#');
expect(inlineNodeList).not.toContain('misub.example');
expect(inlineNodeList).not.toContain('target=nodes');
expect(inlineNodeList).not.toContain('\n');
expect(dataSourceUrl).toBe('https://misub.example/stable-token?base64&callback_token=external');
expect(redirectUrl.toString()).toContain('url=https%3A%2F%2Fmisub.example%2Fstable-token%3Fbase64%26callback_token%3Dexternal');

const callbackWaitUntil = vi.fn();
const base64Response = await handleMisubRequest({
request: new Request(dataSourceUrl, {
headers: { 'User-Agent': 'ClashMeta' }
}),
env: {},
waitUntil: callbackWaitUntil
});
expect(base64Response.status).toBe(200);
expect(atob(await base64Response.text())).toContain('trojan://pass@example.com:443#');
expect(callbackWaitUntil).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[MiSub Request]'));
expect(logSpy).toHaveBeenCalledWith('[MiSub Nodes] Count/Length: 68');
} finally {
logSpy.mockRestore();
}
});

it('switches large external converter redirects to a temporary callback URL', async () => {
it('uses the Base64 subscription URL for large node sets without a callback secret', async () => {
const subscriptions = [{
id: 'sub-a',
name: 'Airport A',
Expand All @@ -154,19 +164,6 @@ describe('handleMisubRequest regression coverage', () => {
},
subscriptions
});
const kvWrites = new Map();
const env = {
CALLBACK_TOKEN_SECRET: 'callback-secret',
MISUB_KV: {
get: vi.fn(async (key) => kvWrites.get(key) || null),
put: vi.fn(async (key, value) => {
kvWrites.set(key, value);
}),
delete: vi.fn(async (key) => {
kvWrites.delete(key);
})
}
};
createAdapter.mockReturnValue(adapter);
const bigNodeList = Array.from({ length: 90 }, (_, index) => `trojan://pass${index}@example.com:443#HK-${index}`).join('\n');
vi.stubGlobal('fetch', vi.fn(async () => new Response(bigNodeList, { status: 200 })));
Expand All @@ -178,23 +175,19 @@ describe('handleMisubRequest regression coverage', () => {
request: new Request('https://misub.example/stable-token?target=clash&refresh=1', {
headers: { 'User-Agent': 'ClashMeta' }
}),
env,
env: {},
waitUntil: vi.fn()
});
const redirectUrl = new URL(response.headers.get('Location'));
const callbackUrl = new URL(redirectUrl.searchParams.get('url'));
const dataSourceUrl = redirectUrl.searchParams.get('url');

expect(response.status).toBe(302);
expect(redirectUrl.origin + redirectUrl.pathname).toBe('https://sub.example/sub');
expect(redirectUrl.searchParams.get('target')).toBe('clash');
expect(callbackUrl.origin + callbackUrl.pathname).toBe('https://misub.example/api/external-nodes-callback');
expect(callbackUrl.searchParams.get('token')).toBeTruthy();
expect(callbackUrl.searchParams.get('encoding')).toBe('base64');
expect(redirectUrl.searchParams.get('url')).not.toContain('trojan://pass0@example.com');
const externalNodeCacheWrites = [...kvWrites.entries()].filter(([key]) => key.startsWith('tmp_external_nodes:'));
expect(externalNodeCacheWrites).toHaveLength(1);
expect(externalNodeCacheWrites[0][1]).toContain('trojan://pass0@example.com:443#');
expect(response.headers.get('X-MiSub-Mode')).toBe('external-redirect-callback');
expect(dataSourceUrl).toBe('https://misub.example/stable-token?base64&callback_token=external');
expect(redirectUrl.toString()).not.toContain('trojan%3A%2F%2Fpass0');
expect(redirectUrl.toString().length).toBeLessThan(2000);
expect(response.headers.get('X-MiSub-Mode')).toBe('external-redirect-v2');
} finally {
logSpy.mockRestore();
}
Expand Down Expand Up @@ -235,14 +228,12 @@ describe('handleMisubRequest regression coverage', () => {
waitUntil: vi.fn()
});
const redirectUrl = new URL(response.headers.get('Location'));
const inlineNodeList = redirectUrl.searchParams.get('url');
const dataSourceUrl = redirectUrl.searchParams.get('url');

expect(response.status).toBe(302);
expect(redirectUrl.origin + redirectUrl.pathname).toBe(expectedEndpoint);
expect(redirectUrl.searchParams.get('target')).toBe('clash');
expect(inlineNodeList).toContain('trojan://pass@example.com:443#');
expect(inlineNodeList).not.toContain('target=nodes');
expect(inlineNodeList).not.toContain('\n');
expect(dataSourceUrl).toBe('https://misub.example/stable-token?base64&callback_token=external');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[MiSub Request]'));
expect(logSpy).toHaveBeenCalledWith('[MiSub Nodes] Count/Length: 51');
} finally {
Expand Down