mirror of
https://git.mirrors.martin98.com/https://github.com/sub-store-org/Sub-Store.git
synced 2025-10-17 01:01:29 +08:00
620 lines
22 KiB
JavaScript
620 lines
22 KiB
JavaScript
import { getPlatformFromHeaders } from '@/utils/user-agent';
|
||
import { ProxyUtils } from '@/core/proxy-utils';
|
||
import { COLLECTIONS_KEY, SUBS_KEY } from '@/constants';
|
||
import { findByName } from '@/utils/database';
|
||
import { getFlowHeaders } from '@/utils/flow';
|
||
import $ from '@/core/app';
|
||
import { failed } from '@/restful/response';
|
||
import { InternalServerError, ResourceNotFoundError } from '@/restful/errors';
|
||
import { produceArtifact } from '@/restful/sync';
|
||
// eslint-disable-next-line no-unused-vars
|
||
import { isIPv4, isIPv6 } from '@/utils';
|
||
import { getISO } from '@/utils/geo';
|
||
import env from '@/utils/env';
|
||
|
||
export default function register($app) {
|
||
$app.get('/share/col/:name', downloadCollection);
|
||
$app.get('/share/sub/:name', downloadSubscription);
|
||
|
||
$app.get('/download/collection/:name', downloadCollection);
|
||
$app.get('/download/:name', downloadSubscription);
|
||
$app.get(
|
||
'/download/collection/:name/api/v1/server/details',
|
||
async (req, res) => {
|
||
req.query.platform = 'JSON';
|
||
req.query.produceType = 'internal';
|
||
req.query.resultFormat = 'nezha';
|
||
await downloadCollection(req, res);
|
||
},
|
||
);
|
||
$app.get('/download/:name/api/v1/server/details', async (req, res) => {
|
||
req.query.platform = 'JSON';
|
||
req.query.produceType = 'internal';
|
||
req.query.resultFormat = 'nezha';
|
||
await downloadSubscription(req, res);
|
||
});
|
||
$app.get(
|
||
'/download/collection/:name/api/v1/monitor/:nezhaIndex',
|
||
async (req, res) => {
|
||
req.query.platform = 'JSON';
|
||
req.query.produceType = 'internal';
|
||
req.query.resultFormat = 'nezha-monitor';
|
||
await downloadCollection(req, res);
|
||
},
|
||
);
|
||
$app.get('/download/:name/api/v1/monitor/:nezhaIndex', async (req, res) => {
|
||
req.query.platform = 'JSON';
|
||
req.query.produceType = 'internal';
|
||
req.query.resultFormat = 'nezha-monitor';
|
||
await downloadSubscription(req, res);
|
||
});
|
||
}
|
||
|
||
async function downloadSubscription(req, res) {
|
||
let { name, nezhaIndex } = req.params;
|
||
name = decodeURIComponent(name);
|
||
nezhaIndex = decodeURIComponent(nezhaIndex);
|
||
|
||
const useMihomoExternal = req.query.target === 'SurgeMac';
|
||
|
||
const platform =
|
||
req.query.target || getPlatformFromHeaders(req.headers) || 'JSON';
|
||
|
||
$.info(
|
||
`正在下载订阅:${name}\n请求 User-Agent: ${
|
||
req.headers['user-agent'] || req.headers['User-Agent']
|
||
}`,
|
||
);
|
||
let {
|
||
url,
|
||
ua,
|
||
content,
|
||
mergeSources,
|
||
ignoreFailedRemoteSub,
|
||
produceType,
|
||
includeUnsupportedProxy,
|
||
resultFormat,
|
||
proxy,
|
||
noCache,
|
||
} = req.query;
|
||
let $options = {};
|
||
if (req.query.$options) {
|
||
try {
|
||
// 支持 `#${encodeURIComponent(JSON.stringify({arg1: "1"}))}`
|
||
$options = JSON.parse(decodeURIComponent(req.query.$options));
|
||
} catch (e) {
|
||
for (const pair of req.query.$options.split('&')) {
|
||
const key = pair.split('=')[0];
|
||
const value = pair.split('=')[1];
|
||
// 部分兼容之前的逻辑 const value = pair.split('=')[1] || true;
|
||
$options[key] =
|
||
value == null || value === ''
|
||
? true
|
||
: decodeURIComponent(value);
|
||
}
|
||
}
|
||
$.info(`传入 $options: ${JSON.stringify($options)}`);
|
||
}
|
||
if (url) {
|
||
url = decodeURIComponent(url);
|
||
$.info(`指定远程订阅 URL: ${url}`);
|
||
}
|
||
if (proxy) {
|
||
proxy = decodeURIComponent(proxy);
|
||
$.info(`指定远程订阅使用代理/策略 proxy: ${proxy}`);
|
||
}
|
||
if (ua) {
|
||
ua = decodeURIComponent(ua);
|
||
$.info(`指定远程订阅 User-Agent: ${ua}`);
|
||
}
|
||
if (content) {
|
||
content = decodeURIComponent(content);
|
||
$.info(`指定本地订阅: ${content}`);
|
||
}
|
||
if (mergeSources) {
|
||
mergeSources = decodeURIComponent(mergeSources);
|
||
$.info(`指定合并来源: ${mergeSources}`);
|
||
}
|
||
if (ignoreFailedRemoteSub != null && ignoreFailedRemoteSub !== '') {
|
||
ignoreFailedRemoteSub = decodeURIComponent(ignoreFailedRemoteSub);
|
||
$.info(`指定忽略失败的远程订阅: ${ignoreFailedRemoteSub}`);
|
||
}
|
||
if (produceType) {
|
||
produceType = decodeURIComponent(produceType);
|
||
$.info(`指定生产类型: ${produceType}`);
|
||
}
|
||
if (includeUnsupportedProxy) {
|
||
includeUnsupportedProxy = decodeURIComponent(includeUnsupportedProxy);
|
||
$.info(`包含不支持的节点: ${includeUnsupportedProxy}`);
|
||
}
|
||
|
||
if (useMihomoExternal) {
|
||
$.info(`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`);
|
||
}
|
||
|
||
if (noCache) {
|
||
$.info(`指定不使用缓存: ${noCache}`);
|
||
}
|
||
|
||
const allSubs = $.read(SUBS_KEY);
|
||
const sub = findByName(allSubs, name);
|
||
if (sub) {
|
||
try {
|
||
let output = await produceArtifact({
|
||
type: 'subscription',
|
||
name,
|
||
platform,
|
||
url,
|
||
ua,
|
||
content,
|
||
mergeSources,
|
||
ignoreFailedRemoteSub,
|
||
produceType,
|
||
produceOpts: {
|
||
'include-unsupported-proxy': includeUnsupportedProxy,
|
||
useMihomoExternal,
|
||
},
|
||
$options,
|
||
proxy,
|
||
noCache,
|
||
});
|
||
let flowInfo;
|
||
if (
|
||
sub.source !== 'local' ||
|
||
['localFirst', 'remoteFirst'].includes(sub.mergeSources)
|
||
) {
|
||
try {
|
||
url =
|
||
`${url || sub.url}`
|
||
.split(/[\r\n]+/)
|
||
.map((i) => i.trim())
|
||
.filter((i) => i.length)?.[0] || '';
|
||
|
||
let $arguments = {};
|
||
const rawArgs = url.split('#');
|
||
url = url.split('#')[0];
|
||
if (rawArgs.length > 1) {
|
||
try {
|
||
// 支持 `#${encodeURIComponent(JSON.stringify({arg1: "1"}))}`
|
||
$arguments = JSON.parse(
|
||
decodeURIComponent(rawArgs[1]),
|
||
);
|
||
} catch (e) {
|
||
for (const pair of rawArgs[1].split('&')) {
|
||
const key = pair.split('=')[0];
|
||
const value = pair.split('=')[1];
|
||
// 部分兼容之前的逻辑 const value = pair.split('=')[1] || true;
|
||
$arguments[key] =
|
||
value == null || value === ''
|
||
? true
|
||
: decodeURIComponent(value);
|
||
}
|
||
}
|
||
}
|
||
if (!$arguments.noFlow) {
|
||
// forward flow headers
|
||
flowInfo = await getFlowHeaders(
|
||
$arguments?.insecure ? `${url}#insecure` : url,
|
||
$arguments.flowUserAgent,
|
||
undefined,
|
||
proxy || sub.proxy,
|
||
$arguments.flowUrl,
|
||
);
|
||
if (flowInfo) {
|
||
res.set('subscription-userinfo', flowInfo);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
$.error(
|
||
`订阅 ${name} 获取流量信息时发生错误: ${JSON.stringify(
|
||
err,
|
||
)}`,
|
||
);
|
||
}
|
||
}
|
||
if (sub.subUserinfo) {
|
||
res.set(
|
||
'subscription-userinfo',
|
||
[sub.subUserinfo, flowInfo].filter((i) => i).join('; '),
|
||
);
|
||
}
|
||
|
||
if (platform === 'JSON') {
|
||
if (resultFormat === 'nezha') {
|
||
output = nezhaTransform(output);
|
||
} else if (resultFormat === 'nezha-monitor') {
|
||
nezhaIndex = /^\d+$/.test(nezhaIndex)
|
||
? parseInt(nezhaIndex, 10)
|
||
: output.findIndex((i) => i.name === nezhaIndex);
|
||
output = await nezhaMonitor(
|
||
output[nezhaIndex],
|
||
nezhaIndex,
|
||
req.query,
|
||
);
|
||
}
|
||
res.set('Content-Type', 'application/json;charset=utf-8').send(
|
||
output,
|
||
);
|
||
} else {
|
||
res.send(output);
|
||
}
|
||
} catch (err) {
|
||
$.notify(
|
||
`🌍 Sub-Store 下载订阅失败`,
|
||
`❌ 无法下载订阅:${name}!`,
|
||
`🤔 原因:${err.message ?? err}`,
|
||
);
|
||
$.error(err.message ?? err);
|
||
failed(
|
||
res,
|
||
new InternalServerError(
|
||
'INTERNAL_SERVER_ERROR',
|
||
`Failed to download subscription: ${name}`,
|
||
`Reason: ${err.message ?? err}`,
|
||
),
|
||
);
|
||
}
|
||
} else {
|
||
$.error(`🌍 Sub-Store 下载订阅失败`, `❌ 未找到订阅:${name}!`);
|
||
failed(
|
||
res,
|
||
new ResourceNotFoundError(
|
||
'RESOURCE_NOT_FOUND',
|
||
`Subscription ${name} does not exist!`,
|
||
),
|
||
404,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function downloadCollection(req, res) {
|
||
let { name, nezhaIndex } = req.params;
|
||
name = decodeURIComponent(name);
|
||
nezhaIndex = decodeURIComponent(nezhaIndex);
|
||
|
||
const useMihomoExternal = req.query.target === 'SurgeMac';
|
||
|
||
const platform =
|
||
req.query.target || getPlatformFromHeaders(req.headers) || 'JSON';
|
||
|
||
const allCols = $.read(COLLECTIONS_KEY);
|
||
const collection = findByName(allCols, name);
|
||
|
||
$.info(
|
||
`正在下载组合订阅:${name}\n请求 User-Agent: ${
|
||
req.headers['user-agent'] || req.headers['User-Agent']
|
||
}`,
|
||
);
|
||
|
||
let {
|
||
ignoreFailedRemoteSub,
|
||
produceType,
|
||
includeUnsupportedProxy,
|
||
resultFormat,
|
||
proxy,
|
||
noCache,
|
||
} = req.query;
|
||
|
||
let $options = {};
|
||
if (req.query.$options) {
|
||
try {
|
||
// 支持 `#${encodeURIComponent(JSON.stringify({arg1: "1"}))}`
|
||
$options = JSON.parse(decodeURIComponent(req.query.$options));
|
||
} catch (e) {
|
||
for (const pair of req.query.$options.split('&')) {
|
||
const key = pair.split('=')[0];
|
||
const value = pair.split('=')[1];
|
||
// 部分兼容之前的逻辑 const value = pair.split('=')[1] || true;
|
||
$options[key] =
|
||
value == null || value === ''
|
||
? true
|
||
: decodeURIComponent(value);
|
||
}
|
||
}
|
||
$.info(`传入 $options: ${JSON.stringify($options)}`);
|
||
}
|
||
|
||
if (proxy) {
|
||
proxy = decodeURIComponent(proxy);
|
||
$.info(`指定远程订阅使用代理/策略 proxy: ${proxy}`);
|
||
}
|
||
|
||
if (ignoreFailedRemoteSub != null && ignoreFailedRemoteSub !== '') {
|
||
ignoreFailedRemoteSub = decodeURIComponent(ignoreFailedRemoteSub);
|
||
$.info(`指定忽略失败的远程订阅: ${ignoreFailedRemoteSub}`);
|
||
}
|
||
if (produceType) {
|
||
produceType = decodeURIComponent(produceType);
|
||
$.info(`指定生产类型: ${produceType}`);
|
||
}
|
||
|
||
if (includeUnsupportedProxy) {
|
||
includeUnsupportedProxy = decodeURIComponent(includeUnsupportedProxy);
|
||
$.info(`包含不支持的节点: ${includeUnsupportedProxy}`);
|
||
}
|
||
if (useMihomoExternal) {
|
||
$.info(`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`);
|
||
}
|
||
if (noCache) {
|
||
$.info(`指定不使用缓存: ${noCache}`);
|
||
}
|
||
|
||
if (collection) {
|
||
try {
|
||
let output = await produceArtifact({
|
||
type: 'collection',
|
||
name,
|
||
platform,
|
||
ignoreFailedRemoteSub,
|
||
produceType,
|
||
produceOpts: {
|
||
'include-unsupported-proxy': includeUnsupportedProxy,
|
||
useMihomoExternal,
|
||
},
|
||
$options,
|
||
proxy,
|
||
noCache,
|
||
});
|
||
|
||
// forward flow header from the first subscription in this collection
|
||
const allSubs = $.read(SUBS_KEY);
|
||
const subnames = collection.subscriptions;
|
||
if (subnames.length > 0) {
|
||
const sub = findByName(allSubs, subnames[0]);
|
||
let flowInfo;
|
||
if (
|
||
sub.source !== 'local' ||
|
||
['localFirst', 'remoteFirst'].includes(sub.mergeSources)
|
||
) {
|
||
try {
|
||
let url =
|
||
`${sub.url}`
|
||
.split(/[\r\n]+/)
|
||
.map((i) => i.trim())
|
||
.filter((i) => i.length)?.[0] || '';
|
||
|
||
let $arguments = {};
|
||
const rawArgs = url.split('#');
|
||
url = url.split('#')[0];
|
||
if (rawArgs.length > 1) {
|
||
try {
|
||
// 支持 `#${encodeURIComponent(JSON.stringify({arg1: "1"}))}`
|
||
$arguments = JSON.parse(
|
||
decodeURIComponent(rawArgs[1]),
|
||
);
|
||
} catch (e) {
|
||
for (const pair of rawArgs[1].split('&')) {
|
||
const key = pair.split('=')[0];
|
||
const value = pair.split('=')[1];
|
||
// 部分兼容之前的逻辑 const value = pair.split('=')[1] || true;
|
||
$arguments[key] =
|
||
value == null || value === ''
|
||
? true
|
||
: decodeURIComponent(value);
|
||
}
|
||
}
|
||
}
|
||
if (!$arguments.noFlow) {
|
||
flowInfo = await getFlowHeaders(
|
||
$arguments?.insecure ? `${url}#insecure` : url,
|
||
$arguments.flowUserAgent,
|
||
undefined,
|
||
proxy || sub.proxy || collection.proxy,
|
||
$arguments.flowUrl,
|
||
);
|
||
if (flowInfo) {
|
||
res.set('subscription-userinfo', flowInfo);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
$.error(
|
||
`组合订阅 ${name} 中的子订阅 ${
|
||
sub.name
|
||
} 获取流量信息时发生错误: ${err.message ?? err}`,
|
||
);
|
||
}
|
||
}
|
||
if (sub.subUserinfo) {
|
||
res.set(
|
||
'subscription-userinfo',
|
||
[sub.subUserinfo, flowInfo].filter((i) => i).join('; '),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (platform === 'JSON') {
|
||
if (resultFormat === 'nezha') {
|
||
output = nezhaTransform(output);
|
||
} else if (resultFormat === 'nezha-monitor') {
|
||
nezhaIndex = /^\d+$/.test(nezhaIndex)
|
||
? parseInt(nezhaIndex, 10)
|
||
: output.findIndex((i) => i.name === nezhaIndex);
|
||
output = await nezhaMonitor(
|
||
output[nezhaIndex],
|
||
nezhaIndex,
|
||
req.query,
|
||
);
|
||
}
|
||
res.set('Content-Type', 'application/json;charset=utf-8').send(
|
||
output,
|
||
);
|
||
} else {
|
||
res.send(output);
|
||
}
|
||
} catch (err) {
|
||
$.notify(
|
||
`🌍 Sub-Store 下载组合订阅失败`,
|
||
`❌ 下载组合订阅错误:${name}!`,
|
||
`🤔 原因:${err}`,
|
||
);
|
||
failed(
|
||
res,
|
||
new InternalServerError(
|
||
'INTERNAL_SERVER_ERROR',
|
||
`Failed to download collection: ${name}`,
|
||
`Reason: ${err.message ?? err}`,
|
||
),
|
||
);
|
||
}
|
||
} else {
|
||
$.error(
|
||
`🌍 Sub-Store 下载组合订阅失败`,
|
||
`❌ 未找到组合订阅:${name}!`,
|
||
);
|
||
failed(
|
||
res,
|
||
new ResourceNotFoundError(
|
||
'RESOURCE_NOT_FOUND',
|
||
`Collection ${name} does not exist!`,
|
||
),
|
||
404,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function nezhaMonitor(proxy, index, query) {
|
||
const result = {
|
||
code: 0,
|
||
message: 'success',
|
||
result: [],
|
||
};
|
||
|
||
try {
|
||
const { isLoon, isSurge } = $.env;
|
||
if (!isLoon && !isSurge)
|
||
throw new Error('仅支持 Loon 和 Surge(ability=http-client-policy)');
|
||
const node = ProxyUtils.produce([proxy], isLoon ? 'Loon' : 'Surge');
|
||
if (!node) throw new Error('当前客户端不兼容此节点');
|
||
const monitors = proxy._monitors || [
|
||
{
|
||
name: 'Cloudflare',
|
||
url: 'http://cp.cloudflare.com/generate_204',
|
||
method: 'HEAD',
|
||
number: 3,
|
||
timeout: 2000,
|
||
},
|
||
{
|
||
name: 'Google',
|
||
url: 'http://www.google.com/generate_204',
|
||
method: 'HEAD',
|
||
number: 3,
|
||
timeout: 2000,
|
||
},
|
||
];
|
||
const number =
|
||
query.number || Math.max(...monitors.map((i) => i.number)) || 3;
|
||
for (const monitor of monitors) {
|
||
const interval = 10 * 60 * 1000;
|
||
const data = {
|
||
monitor_id: monitors.indexOf(monitor),
|
||
server_id: index,
|
||
monitor_name: monitor.name,
|
||
server_name: proxy.name,
|
||
created_at: [],
|
||
avg_delay: [],
|
||
};
|
||
for (let index = 0; index < number; index++) {
|
||
const startedAt = Date.now();
|
||
try {
|
||
await $.http[(monitor.method || 'HEAD').toLowerCase()]({
|
||
timeout: monitor.timeout || 2000,
|
||
url: monitor.url,
|
||
'policy-descriptor': node,
|
||
node,
|
||
});
|
||
const latency = Date.now() - startedAt;
|
||
$.info(`${monitor.name} latency: ${latency}`);
|
||
data.avg_delay.push(latency);
|
||
} catch (e) {
|
||
$.error(e);
|
||
data.avg_delay.push(0);
|
||
}
|
||
|
||
data.created_at.push(
|
||
Date.now() - interval * (monitor.number - index - 1),
|
||
);
|
||
}
|
||
|
||
result.result.push(data);
|
||
}
|
||
} catch (e) {
|
||
$.error(e);
|
||
result.result.push({
|
||
monitor_id: 0,
|
||
server_id: 0,
|
||
monitor_name: `❌ ${e.message ?? e}`,
|
||
server_name: proxy.name,
|
||
created_at: [Date.now()],
|
||
avg_delay: [0],
|
||
});
|
||
}
|
||
|
||
return JSON.stringify(result, null, 2);
|
||
}
|
||
function nezhaTransform(output) {
|
||
const result = {
|
||
code: 0,
|
||
message: 'success',
|
||
result: [],
|
||
};
|
||
output.map((proxy, index) => {
|
||
// 如果节点上有数据 就取节点上的数据
|
||
let CountryCode = proxy._geo?.countryCode || proxy._geo?.country;
|
||
// 简单判断下
|
||
if (!/^[a-z]{2}$/i.test(CountryCode)) {
|
||
CountryCode = getISO(proxy.name);
|
||
}
|
||
// 简单判断下
|
||
if (/^[a-z]{2}$/i.test(CountryCode)) {
|
||
// 如果节点上有数据 就取节点上的数据
|
||
let now = Math.round(new Date().getTime() / 1000);
|
||
let time = proxy._unavailable ? 0 : now;
|
||
|
||
const uptime = parseInt(proxy._uptime || 0, 10);
|
||
|
||
result.result.push({
|
||
id: index,
|
||
name: proxy.name,
|
||
tag: `${proxy._tag ?? ''}`,
|
||
last_active: time,
|
||
// 暂时不用处理 现在 VPings App 端的接口支持域名查询
|
||
// 其他场景使用 自己在 Sub-Store 加一步域名解析
|
||
valid_ip: proxy._IP || proxy.server,
|
||
ipv4: proxy._IPv4 || proxy.server,
|
||
ipv6: proxy._IPv6 || (isIPv6(proxy.server) ? proxy.server : ''),
|
||
host: {
|
||
Platform: 'Sub-Store',
|
||
PlatformVersion: env.version,
|
||
CPU: [],
|
||
MemTotal: 1024,
|
||
DiskTotal: 1024,
|
||
SwapTotal: 1024,
|
||
Arch: '',
|
||
Virtualization: '',
|
||
BootTime: now - uptime,
|
||
CountryCode, // 目前需要
|
||
Version: '0.0.1',
|
||
},
|
||
status: {
|
||
CPU: 0,
|
||
MemUsed: 0,
|
||
SwapUsed: 0,
|
||
DiskUsed: 0,
|
||
NetInTransfer: 0,
|
||
NetOutTransfer: 0,
|
||
NetInSpeed: 0,
|
||
NetOutSpeed: 0,
|
||
Uptime: uptime,
|
||
Load1: 0,
|
||
Load5: 0,
|
||
Load15: 0,
|
||
TcpConnCount: 0,
|
||
UdpConnCount: 0,
|
||
ProcessCount: 0,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
return JSON.stringify(result, null, 2);
|
||
}
|