Compare commits
3 Commits
2c0773f4f7
...
b231e78498
| Author | SHA1 | Date | |
|---|---|---|---|
| b231e78498 | |||
| ec6b3cb93f | |||
| 8f28ae3c67 |
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
out
|
||||
subs.json
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3000 \
|
||||
AUTO_OPEN=0
|
||||
|
||||
COPY package*.json ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
|
||||
|
||||
COPY sub2proxy.js ./
|
||||
COPY public ./public
|
||||
COPY templates ./templates
|
||||
|
||||
RUN mkdir -p out && printf '[]\n' > subs.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "sub2proxy.js"]
|
||||
@@ -34,3 +34,36 @@ node sub2proxy.js
|
||||
node sub2proxy.js --cli
|
||||
```
|
||||
配置会自动输出到 `out/CatMata-sub.yaml` 和 `out/CatMata-sub.json`,并归档于 `out/history`。
|
||||
|
||||
## Docker / 飞牛部署
|
||||
|
||||
### 使用 Compose
|
||||
先确认当前目录下存在 `subs.json`,再运行:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
```text
|
||||
http://飞牛IP:3000
|
||||
```
|
||||
|
||||
需要持久化的数据:
|
||||
* `subs.json` - 订阅源配置,控制面板修改后会写入这里
|
||||
* `out/` - 当前输出和历史配置
|
||||
* `templates/` - Clash / Sing-box 模板,默认只读挂载
|
||||
|
||||
### 手机/电脑一键导入
|
||||
如果手机或电脑通过 `http://飞牛IP:3000` 打开控制面板,导入链接会自动使用这个地址生成,Clash 客户端可以直接从飞牛拉取配置。
|
||||
|
||||
如果你通过反向代理、域名或 HTTPS 访问,建议在 `docker-compose.yml` 中设置:
|
||||
```yaml
|
||||
PUBLIC_BASE_URL: "https://你的域名"
|
||||
```
|
||||
|
||||
如果使用局域网固定 IP,也可以显式设置:
|
||||
```yaml
|
||||
PUBLIC_BASE_URL: "http://192.168.1.10:3000"
|
||||
```
|
||||
|
||||
注意:不要让导入链接使用 `127.0.0.1` 或 `localhost`,手机/电脑上的 Clash 会把它理解为设备自己,而不是飞牛服务器。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
sub2proxy:
|
||||
build: .
|
||||
container_name: sub2proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PORT: "3000"
|
||||
HOST: "0.0.0.0"
|
||||
AUTO_OPEN: "0"
|
||||
# Set this when clients should import through a fixed LAN IP, domain, or reverse proxy.
|
||||
# PUBLIC_BASE_URL: "http://192.168.1.10:3000"
|
||||
volumes:
|
||||
- ./subs.json:/app/subs.json
|
||||
- ./out:/app/out
|
||||
- ./templates:/app/templates:ro
|
||||
+128
-23
@@ -710,7 +710,8 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称 (Tag)</th>
|
||||
<th>解析名 (Tag)</th>
|
||||
<th>代理名</th>
|
||||
<th>协议 (Type)</th>
|
||||
<th>服务器 (Server)</th>
|
||||
<th>端口 (Port)</th>
|
||||
@@ -719,7 +720,7 @@
|
||||
</thead>
|
||||
<tbody id="proxies-list">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
请先点击右上角 “一键拉取” 按钮解析节点
|
||||
</td>
|
||||
</tr>
|
||||
@@ -765,11 +766,12 @@
|
||||
<th>节点数量</th>
|
||||
<th>本地下载</th>
|
||||
<th>一键导入</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-list">
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
暂无生成历史记录,运行生成配置后将在此归档
|
||||
</td>
|
||||
</tr>
|
||||
@@ -856,14 +858,28 @@
|
||||
let subscriptions = [];
|
||||
let proxies = []; // In-memory editable proxies
|
||||
let originalDns = { clash: '', singbox: '' };
|
||||
let appConfig = { publicBaseUrl: '' };
|
||||
|
||||
// On Load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
lucide.createIcons();
|
||||
await loadAppConfig();
|
||||
loadSubscriptions();
|
||||
loadDnsTemplates();
|
||||
});
|
||||
|
||||
async function loadAppConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/app-config');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
appConfig.publicBaseUrl = (data.publicBaseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load app config:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Logging helper
|
||||
function log(message, type = 'info') {
|
||||
const logs = document.getElementById('console-logs');
|
||||
@@ -1093,6 +1109,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[ch]);
|
||||
}
|
||||
|
||||
function getParsedName(node) {
|
||||
return node.name || node.tag || '';
|
||||
}
|
||||
|
||||
function getProxyName(node) {
|
||||
return node.proxyName || node.originalName || getParsedName(node);
|
||||
}
|
||||
|
||||
function renderProxiesTable() {
|
||||
const list = document.getElementById('proxies-list');
|
||||
const counter = document.getElementById('proxies-counter');
|
||||
@@ -1103,7 +1137,7 @@
|
||||
if (proxies.length === 0) {
|
||||
list.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
未提取到任何代理节点
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1113,15 +1147,26 @@
|
||||
|
||||
proxies.forEach((node, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
const parsedName = getParsedName(node);
|
||||
const proxyName = getProxyName(node);
|
||||
const nodeType = node.type || '';
|
||||
const server = node.server || '';
|
||||
const port = node.port || node.server_port || '';
|
||||
tr.innerHTML = `
|
||||
<td style="font-weight: 500;">${node.name || node.tag}</td>
|
||||
<td><span class="node-proto proto-${(node.type || '').toLowerCase()}">${node.type}</span></td>
|
||||
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.server}</td>
|
||||
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.port || node.server_port}</td>
|
||||
<td style="font-weight: 500;" title="${escapeHtml(parsedName)}">${escapeHtml(parsedName)}</td>
|
||||
<td title="${escapeHtml(proxyName)}">${escapeHtml(proxyName)}</td>
|
||||
<td><span class="node-proto proto-${escapeHtml(nodeType.toLowerCase())}">${escapeHtml(nodeType)}</span></td>
|
||||
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${escapeHtml(server)}</td>
|
||||
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${escapeHtml(port)}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="openNodeDrawer(${idx})">
|
||||
<i data-lucide="sliders" style="width: 12px; height: 12px;"></i>修改参数
|
||||
</button>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="openNodeDrawer(${idx})">
|
||||
<i data-lucide="sliders" style="width: 12px; height: 12px;"></i>修改参数
|
||||
</button>
|
||||
<button class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteProxy(${idx})">
|
||||
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
list.appendChild(tr);
|
||||
@@ -1129,6 +1174,16 @@
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function deleteProxy(idx) {
|
||||
const nodeName = getParsedName(proxies[idx]);
|
||||
if (confirm(`确定要删除代理节点 "${nodeName}" 吗?\n(注:这仅会从当前页面内存中删除。如需应用更改,请在删除后点击右上角“保存并生成配置”按钮重新生成配置文件)`)) {
|
||||
log(`在内存中删除代理节点: ${nodeName}`);
|
||||
proxies.splice(idx, 1);
|
||||
renderProxiesTable();
|
||||
showToast('代理节点已删除');
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Drawer for Editing Nodes
|
||||
let currentEditNodeIndex = null;
|
||||
|
||||
@@ -1139,17 +1194,18 @@
|
||||
const container = document.getElementById('node-edit-form');
|
||||
const overlay = document.getElementById('node-drawer-overlay');
|
||||
|
||||
title.textContent = `修改节点: ${node.name || node.tag}`;
|
||||
title.textContent = `修改节点: ${getParsedName(node)}`;
|
||||
container.innerHTML = '';
|
||||
|
||||
const commonFields = [
|
||||
{ name: 'name', label: '节点 Tag/Name', val: node.name || node.tag, type: 'text' },
|
||||
{ name: 'name', label: '解析名 (Tag)', val: getParsedName(node), type: 'text' },
|
||||
{ name: 'proxyName', label: '代理名', val: getProxyName(node), type: 'text', readonly: true },
|
||||
{ name: 'server', label: '服务器 IP/域名', val: node.server, type: 'text' },
|
||||
{ name: 'port', label: '端口', val: node.port || node.server_port, type: 'number' }
|
||||
];
|
||||
|
||||
commonFields.forEach(f => {
|
||||
container.appendChild(createFormField(f.name, f.label, f.val, f.type));
|
||||
container.appendChild(createFormField(f.name, f.label, f.val, f.type, { readonly: f.readonly }));
|
||||
});
|
||||
|
||||
const t = (node.type || '').toLowerCase();
|
||||
@@ -1190,12 +1246,13 @@
|
||||
overlay.classList.add('active');
|
||||
}
|
||||
|
||||
function createFormField(name, label, value, type) {
|
||||
function createFormField(name, label, value, type, options = {}) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'form-group';
|
||||
const readonlyAttr = options.readonly ? ' readonly aria-readonly="true"' : '';
|
||||
div.innerHTML = `
|
||||
<label for="edit-${name}">${label}</label>
|
||||
<input type="${type}" id="edit-${name}" class="form-control" value="${value !== undefined ? value : ''}">
|
||||
<label for="edit-${escapeHtml(name)}">${escapeHtml(label)}</label>
|
||||
<input type="${escapeHtml(type)}" id="edit-${escapeHtml(name)}" class="form-control" value="${escapeHtml(value !== undefined ? value : '')}"${readonlyAttr}>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
@@ -1362,6 +1419,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHistory(yamlFile, jsonFile, timeStr) {
|
||||
if (confirm(`确定要删除该历史生成记录吗?\n生成时间: ${timeStr}\n(注意:此操作将物理删除对应的配置文件归档且不可逆!)`)) {
|
||||
log(`正在请求删除历史记录: ${timeStr}`);
|
||||
try {
|
||||
const res = await fetch('/api/history/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ yamlFile, jsonFile })
|
||||
});
|
||||
if (res.ok) {
|
||||
log(`成功删除历史记录: ${timeStr}`, 'success');
|
||||
showToast('历史记录删除成功');
|
||||
loadHistory();
|
||||
} else {
|
||||
const errText = await res.text();
|
||||
log(`删除历史记录失败: ${errText}`, 'error');
|
||||
showToast('删除历史记录失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
log(`删除历史记录异常: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getClientImportOrigin() {
|
||||
if (appConfig.publicBaseUrl) {
|
||||
return appConfig.publicBaseUrl;
|
||||
}
|
||||
|
||||
const localHosts = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
if (!localHosts.has(window.location.hostname)) {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
return `${window.location.protocol}//127.0.0.1${port}`;
|
||||
}
|
||||
|
||||
function renderHistoryList(list) {
|
||||
const tbody = document.getElementById('history-list');
|
||||
tbody.innerHTML = '';
|
||||
@@ -1369,7 +1464,7 @@
|
||||
if (!list || list.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
暂无生成历史记录,运行生成配置后将在此归档
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1378,14 +1473,19 @@
|
||||
}
|
||||
|
||||
const origin = window.location.origin;
|
||||
const clientOrigin = getClientImportOrigin();
|
||||
|
||||
list.forEach(item => {
|
||||
const yamlDownloadUrl = `${origin}/api/download?file=${item.yamlFile}`;
|
||||
const jsonDownloadUrl = `${origin}/api/download?file=${item.jsonFile}`;
|
||||
const yamlFileParam = encodeURIComponent(item.yamlFile);
|
||||
const jsonFileParam = encodeURIComponent(item.jsonFile);
|
||||
const yamlDownloadUrl = `${origin}/api/download?file=${yamlFileParam}`;
|
||||
const jsonDownloadUrl = `${origin}/api/download?file=${jsonFileParam}`;
|
||||
const yamlImportProfileUrl = `${clientOrigin}/api/profile?file=${yamlFileParam}`;
|
||||
const jsonImportProfileUrl = `${clientOrigin}/api/profile?file=${jsonFileParam}`;
|
||||
|
||||
// Construct client URI schemes
|
||||
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlDownloadUrl)}&name=${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
|
||||
const singboxImportUrl = `sing-box://import-remote-profile?url=${encodeURIComponent(jsonDownloadUrl)}#${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
|
||||
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlImportProfileUrl)}&name=${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
|
||||
const singboxImportUrl = `sing-box://import-remote-profile?url=${encodeURIComponent(jsonImportProfileUrl)}#${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
@@ -1411,6 +1511,11 @@
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteHistory('${item.yamlFile}', '${item.jsonFile}', '${item.timeStr}')">
|
||||
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>删除
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
+128
-8
@@ -314,6 +314,7 @@ function parseSingboxNodes(content) {
|
||||
const usedNames = new Set();
|
||||
|
||||
function renameNode(node, subName, isClash = true) {
|
||||
const originalName = isClash ? (node.name || node.tag || '') : (node.tag || node.name || '');
|
||||
const typeStr = (node.type || '').toLowerCase();
|
||||
const protoClean = getProtoName(typeStr);
|
||||
const baseName = `${subName}-${protoClean}`;
|
||||
@@ -326,6 +327,7 @@ function renameNode(node, subName, isClash = true) {
|
||||
}
|
||||
|
||||
usedNames.add(newName.toLowerCase());
|
||||
node.proxyName = originalName || newName;
|
||||
|
||||
if (isClash) {
|
||||
node.name = newName;
|
||||
@@ -380,6 +382,12 @@ function detectTemplates() {
|
||||
return { yamlTpl, jsonTpl };
|
||||
}
|
||||
|
||||
function stripUiOnlyNodeFields(node) {
|
||||
const cleanNode = JSON.parse(JSON.stringify(node));
|
||||
delete cleanNode.proxyName;
|
||||
return cleanNode;
|
||||
}
|
||||
|
||||
function replaceNodeRefs(data, oldNodesSet, fallbackNode) {
|
||||
if (data && typeof data === 'object') {
|
||||
if (Array.isArray(data)) {
|
||||
@@ -403,7 +411,10 @@ function replaceNodeRefs(data, oldNodesSet, fallbackNode) {
|
||||
// Configuration paths
|
||||
const subsFile = 'subs.json';
|
||||
const outDir = 'out';
|
||||
const PORT = 3000;
|
||||
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const PUBLIC_BASE_URL = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
const AUTO_OPEN = !['0', 'false', 'no', 'off'].includes(String(process.env.AUTO_OPEN || '1').toLowerCase());
|
||||
|
||||
// Central configuration generation function
|
||||
function generateConfigs(rawClashNodes, dns = null) {
|
||||
@@ -411,10 +422,11 @@ function generateConfigs(rawClashNodes, dns = null) {
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
const clashNodes = rawClashNodes.map(stripUiOnlyNodeFields);
|
||||
|
||||
// 1. Process Clash Config
|
||||
const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {};
|
||||
clashConfig.proxies = rawClashNodes;
|
||||
clashConfig.proxies = clashNodes;
|
||||
|
||||
if (dns && dns.clash) {
|
||||
clashConfig.dns = yaml.load(dns.clash);
|
||||
@@ -422,7 +434,7 @@ function generateConfigs(rawClashNodes, dns = null) {
|
||||
|
||||
const groupNames = new Set((clashConfig['proxy-groups'] || []).map(g => g.name));
|
||||
const specialNames = new Set(['DIRECT', 'REJECT', 'PASS', 'COMPATIBLE']);
|
||||
const newNodeNames = rawClashNodes.map(n => n.name);
|
||||
const newNodeNames = clashNodes.map(n => n.name);
|
||||
|
||||
for (const group of (clashConfig['proxy-groups'] || [])) {
|
||||
const oldProxies = group.proxies || [];
|
||||
@@ -438,7 +450,7 @@ function generateConfigs(rawClashNodes, dns = null) {
|
||||
|
||||
// 2. Process Sing-box Config
|
||||
const sbConfig = JSON.parse(fs.readFileSync(jsonTpl, 'utf-8')) || {};
|
||||
const allSingboxNodes = rawClashNodes.map(n => {
|
||||
const allSingboxNodes = clashNodes.map(n => {
|
||||
const nodeCopy = JSON.parse(JSON.stringify(n));
|
||||
return clashToSingbox(nodeCopy);
|
||||
});
|
||||
@@ -571,6 +583,20 @@ function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/health
|
||||
if (pathname === '/api/health' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/app-config
|
||||
if (pathname === '/api/app-config' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ publicBaseUrl: PUBLIC_BASE_URL }));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/subs
|
||||
if (pathname === '/api/subs' && req.method === 'GET') {
|
||||
if (fs.existsSync(subsFile)) {
|
||||
@@ -653,6 +679,7 @@ function startServer() {
|
||||
renameNode(sbNode, subName, false);
|
||||
|
||||
const cNode = singboxToClash(sbNode);
|
||||
cNode.proxyName = sbNode.proxyName || cNode.name;
|
||||
allClashNodes.push(cNode);
|
||||
}
|
||||
}
|
||||
@@ -733,15 +760,108 @@ function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: GET /api/profile
|
||||
// Remote import clients should receive the profile body directly, not as a browser attachment.
|
||||
if (pathname === '/api/profile' && req.method === 'GET') {
|
||||
const fileName = parsedUrl.searchParams.get('file');
|
||||
if (!fileName) {
|
||||
res.writeHead(400);
|
||||
res.end('Missing file parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const safeRegex = /^CatMata-sub-\d{8}-\d{6}\.(yaml|json)$/;
|
||||
if (!safeRegex.test(fileName)) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid file name');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(outDir, 'history', fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const isYaml = fileName.endsWith('.yaml');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': isYaml ? 'text/yaml; charset=utf-8' : 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
res.end(fs.readFileSync(filePath));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: POST /api/history/delete
|
||||
if (pathname === '/api/history/delete' && req.method === 'POST') {
|
||||
try {
|
||||
const { yamlFile, jsonFile } = await readJsonBody(req);
|
||||
if (!yamlFile || !jsonFile) {
|
||||
res.writeHead(400);
|
||||
res.end('Missing yamlFile or jsonFile parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
const safeRegex = /^CatMata-sub-\d{8}-\d{6}\.(yaml|json)$/;
|
||||
if (!safeRegex.test(yamlFile) || !safeRegex.test(jsonFile)) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid file name');
|
||||
return;
|
||||
}
|
||||
|
||||
const historyDir = path.join(outDir, 'history');
|
||||
const yamlPath = path.join(historyDir, yamlFile);
|
||||
const jsonPath = path.join(historyDir, jsonFile);
|
||||
|
||||
// Delete physical files
|
||||
if (fs.existsSync(yamlPath)) {
|
||||
fs.unlinkSync(yamlPath);
|
||||
}
|
||||
if (fs.existsSync(jsonPath)) {
|
||||
fs.unlinkSync(jsonPath);
|
||||
}
|
||||
|
||||
// Update history.json
|
||||
const historyJsonPath = path.join(historyDir, 'history.json');
|
||||
let historyList = [];
|
||||
if (fs.existsSync(historyJsonPath)) {
|
||||
try {
|
||||
historyList = JSON.parse(fs.readFileSync(historyJsonPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse history.json, resetting:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
historyList = historyList.filter(item => item.yamlFile !== yamlFile || item.jsonFile !== jsonFile);
|
||||
|
||||
fs.writeFileSync(historyJsonPath, JSON.stringify(historyList, null, 2), 'utf-8');
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
} catch (e) {
|
||||
res.writeHead(500);
|
||||
res.end(e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, HOST, () => {
|
||||
const displayHost = HOST === '0.0.0.0' || HOST === '::' ? 'localhost' : HOST;
|
||||
console.log(`CatMata Sub2Proxy 控制面板已启动!`);
|
||||
console.log(`本地访问地址: http://localhost:${PORT}`);
|
||||
console.log(`正在自动打开默认浏览器...`);
|
||||
openBrowser(`http://localhost:${PORT}`);
|
||||
console.log(`访问地址: http://${displayHost}:${PORT}`);
|
||||
if (PUBLIC_BASE_URL) {
|
||||
console.log(`客户端导入地址基准: ${PUBLIC_BASE_URL}`);
|
||||
}
|
||||
if (AUTO_OPEN) {
|
||||
console.log(`正在自动打开默认浏览器...`);
|
||||
openBrowser(`http://localhost:${PORT}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user