refactor: 重构文件存储路径并清理冗余代码
- 将项目文件存储从 public/uploads 迁移至项目根目录专用目录 - 更新所有前端页面的路径提示文本与API请求路径 - 移除冗余的列车详情展开收起功能及相关代码 - 重构服务器端文件路径处理逻辑,增强路径访问安全性 - 清理旧注释和未使用的变量与函数
This commit is contained in:
@@ -1251,7 +1251,7 @@
|
|||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/files/content?path=uploads/list.txt');
|
const res = await fetch('/api/files/content?path=list.txt');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
listData = [];
|
listData = [];
|
||||||
if (data.success && data.content) {
|
if (data.success && data.content) {
|
||||||
@@ -1418,7 +1418,7 @@
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
path: 'uploads/list.txt',
|
path: 'list.txt',
|
||||||
content: content + '\n'
|
content: content + '\n'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -857,7 +857,6 @@
|
|||||||
// --- State ---
|
// --- State ---
|
||||||
let allFiles = [];
|
let allFiles = [];
|
||||||
let currentPath = 'root';
|
let currentPath = 'root';
|
||||||
let expandedTrains = new Set();
|
|
||||||
let stationCodeMap = {};
|
let stationCodeMap = {};
|
||||||
let broadcastingStations = new Set();
|
let broadcastingStations = new Set();
|
||||||
|
|
||||||
@@ -951,101 +950,26 @@
|
|||||||
if (!state.activeTrains || state.activeTrains.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active trains</div>';
|
if (!state.activeTrains || state.activeTrains.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active trains</div>';
|
||||||
|
|
||||||
state.activeTrains.forEach(tr => {
|
state.activeTrains.forEach(tr => {
|
||||||
const isExpanded = expandedTrains.has(tr.trainId);
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
|
|
||||||
let detailsHtml = '';
|
|
||||||
if (tr.tripLogs && tr.tripLogs.length > 0) {
|
|
||||||
detailsHtml = tr.tripLogs.map(log => {
|
|
||||||
const time = new Date(log.time).toLocaleTimeString();
|
|
||||||
const stCode = stationCodeMap[log.station] || log.station;
|
|
||||||
return `
|
|
||||||
<div class="trip-log">
|
|
||||||
<span>${time}</span>
|
|
||||||
<span>${stCode}</span>
|
|
||||||
<span>${log.action}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
detailsHtml = '<div style="font-style:italic">No trip history</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="status-main" onclick="toggleTrain('${tr.trainId}')">
|
<div class="status-main">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">${tr.trainId}</div>
|
<div class="item-title">${tr.trainId}</div>
|
||||||
<div class="item-sub">${currentStCode}</div>
|
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||||
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
<button class="btn-icon" onclick="deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
|
|
||||||
<div style="margin-bottom:8px;font-weight:600">Trip Logs</div>
|
|
||||||
${detailsHtml}
|
|
||||||
<div style="margin-top:10px;text-align:right">
|
|
||||||
<button class="btn-icon" onclick="event.stopPropagation(); deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
list.appendChild(div);
|
list.appendChild(div);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Network Statistics
|
|
||||||
const statsDiv = document.createElement('div');
|
|
||||||
statsDiv.className = 'status-item';
|
|
||||||
const isStatsExpanded = expandedTrains.has('__STATS__');
|
|
||||||
|
|
||||||
let statsHtml = '<div style="padding:10px;text-align:center;color:var(--text-muted)">No data</div>';
|
|
||||||
if (state.travelTimes) {
|
|
||||||
const keys = Object.keys(state.travelTimes).sort();
|
|
||||||
if (keys.length > 0) {
|
|
||||||
statsHtml = keys.map(key => {
|
|
||||||
const times = state.travelTimes[key];
|
|
||||||
const [from, to] = key.split('|');
|
|
||||||
const fromCode = stationCodeMap[from] || from;
|
|
||||||
const toCode = stationCodeMap[to] || to;
|
|
||||||
const avg = Math.ceil(times.reduce((a, b) => a + b, 0) / times.length);
|
|
||||||
return `
|
|
||||||
<div class="trip-log">
|
|
||||||
<span>${fromCode} → ${toCode}</span>
|
|
||||||
<span style="text-align:right; color:var(--success)">${avg}s</span>
|
|
||||||
<span style="text-align:right; font-size:0.75em; opacity:0.7">(${times.length})</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
statsDiv.innerHTML = `
|
|
||||||
<div class="status-main" onclick="toggleTrain('__STATS__')" style="background:var(--bg-body); border-top:1px solid var(--bg-border)">
|
|
||||||
<div class="item-info">
|
|
||||||
<div class="item-title" style="font-size:0.85rem; color:var(--text-muted)">NETWORK STATISTICS</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
|
||||||
<button class="btn-icon" onclick="event.stopPropagation(); deleteStats()" style="display:inline-flex" title="Clear Statistics">${ICONS.trash}</button>
|
|
||||||
<div style="color:var(--text-muted)">${isStatsExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-details ${isStatsExpanded ? 'open' : ''}" id="details-__STATS__">
|
|
||||||
<div style="display:grid; grid-template-columns: 1fr 40px 30px; gap:10px; margin-bottom:5px; font-weight:600; font-size:0.75rem; color:var(--text-muted)">
|
|
||||||
<span>ROUTE</span>
|
|
||||||
<span style="text-align:right">AVG</span>
|
|
||||||
<span style="text-align:right">CNT</span>
|
|
||||||
</div>
|
|
||||||
${statsHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
list.appendChild(statsDiv);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Removed separate Travel Times renderer */
|
|
||||||
|
|
||||||
function showRadioStream() {
|
function showRadioStream() {
|
||||||
document.getElementById('file-manager-view').style.display = 'none';
|
document.getElementById('file-manager-view').style.display = 'none';
|
||||||
@@ -1120,18 +1044,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTrain(id) {
|
|
||||||
if (expandedTrains.has(id)) expandedTrains.delete(id);
|
|
||||||
else expandedTrains.add(id);
|
|
||||||
// We need to re-render to update icons/state, but since state updates often,
|
|
||||||
// just toggling the DOM element is smoother until next update
|
|
||||||
const details = document.getElementById(`details-${id}`);
|
|
||||||
if (details) {
|
|
||||||
details.classList.toggle('open');
|
|
||||||
// Force re-render on next update will respect the Set
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLog(logText) {
|
function addLog(logText) {
|
||||||
const container = document.getElementById('console-logs');
|
const container = document.getElementById('console-logs');
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -1334,14 +1246,6 @@
|
|||||||
await fetch(`/api/trains/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
await fetch(`/api/trains/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteStats() {
|
|
||||||
if (!confirm('Are you sure you want to clear all network travel time statistics?')) return;
|
|
||||||
const res = await fetch('/api/stats', { method: 'DELETE' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) showToast('Success', 'Network statistics cleared');
|
|
||||||
else showToast('Error', data.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerUpload() {
|
function triggerUpload() {
|
||||||
if (currentPath === 'config') document.getElementById('upload-config').click();
|
if (currentPath === 'config') document.getElementById('upload-config').click();
|
||||||
else if (currentPath === 'general') document.getElementById('upload-general').click();
|
else if (currentPath === 'general') document.getElementById('upload-general').click();
|
||||||
|
|||||||
@@ -184,19 +184,6 @@
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.train-details {
|
|
||||||
display: none;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 12px 20px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-top: 1px solid var(--bg-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.train-details.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trip-log {
|
.trip-log {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr auto;
|
grid-template-columns: 80px 1fr auto;
|
||||||
@@ -380,7 +367,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
let expandedTrains = new Set();
|
|
||||||
let stationCodeMap = {};
|
let stationCodeMap = {};
|
||||||
let broadcastingStations = new Set();
|
let broadcastingStations = new Set();
|
||||||
let messageLog = [];
|
let messageLog = [];
|
||||||
@@ -526,27 +512,8 @@
|
|||||||
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活跃列车</div>';
|
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活跃列车</div>';
|
||||||
} else {
|
} else {
|
||||||
state.activeTrains.forEach(tr => {
|
state.activeTrains.forEach(tr => {
|
||||||
const isExpanded = expandedTrains.has(tr.trainId);
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item train-item';
|
div.className = 'status-item train-item';
|
||||||
div.onclick = () => toggleTrain(tr.trainId);
|
|
||||||
|
|
||||||
let detailsHtml = '';
|
|
||||||
if (tr.tripLogs && tr.tripLogs.length > 0) {
|
|
||||||
detailsHtml = tr.tripLogs.slice().reverse().map(log => {
|
|
||||||
const time = new Date(log.time).toLocaleTimeString();
|
|
||||||
const stCode = stationCodeMap[log.station] || log.station;
|
|
||||||
return `
|
|
||||||
<div class="trip-log">
|
|
||||||
<span>${time}</span>
|
|
||||||
<span style="color:var(--text-primary)">${stCode}</span>
|
|
||||||
<span style="color:${log.action === 'Arrival' ? 'var(--success)' : 'var(--warning)'}">${log.action === 'Arrival' ? '到达' : '出发'}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
detailsHtml = '<div style="font-style:italic">暂无运行历史</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||||
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
||||||
@@ -556,13 +523,9 @@
|
|||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
|
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
|
||||||
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : '运行中'}</span></div>
|
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中')}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||||
</div>
|
|
||||||
<div class="train-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
|
|
||||||
<div style="margin-bottom:8px;font-weight:600;font-size:0.7rem;text-transform:uppercase;color:var(--text-muted)">运行日志 (Trip Logs)</div>
|
|
||||||
${detailsHtml}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -572,15 +535,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTrain(id) {
|
|
||||||
if (expandedTrains.has(id)) expandedTrains.delete(id);
|
|
||||||
else expandedTrains.add(id);
|
|
||||||
const details = document.getElementById(`details-${id}`);
|
|
||||||
if (details) {
|
|
||||||
details.classList.toggle('open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStationDots() {
|
function updateStationDots() {
|
||||||
const dots = document.querySelectorAll('[id^="station-dot-"]');
|
const dots = document.querySelectorAll('[id^="station-dot-"]');
|
||||||
dots.forEach(dot => {
|
dots.forEach(dot => {
|
||||||
|
|||||||
@@ -397,7 +397,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-subtitle">对 uploads/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
|
<div class="panel-subtitle">对 /opt/CC-StationBroadcast/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn" onclick="openAddModal()">添加</button>
|
<button class="btn" onclick="openAddModal()">添加</button>
|
||||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
@@ -545,7 +545,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
const res = await fetch('/api/files/content?path=uploads/list.txt');
|
const res = await fetch('/api/files/content?path=list.txt');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
listData = [];
|
listData = [];
|
||||||
if (data.success && data.content) {
|
if (data.success && data.content) {
|
||||||
@@ -704,7 +704,7 @@
|
|||||||
const res = await fetch('/api/files/content', {
|
const res = await fetch('/api/files/content', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path: 'uploads/list.txt', content: content + '\n' })
|
body: JSON.stringify({ path: 'list.txt', content: content + '\n' })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.success) throw new Error(data.message || 'Save failed');
|
if (!data.success) throw new Error(data.message || 'Save failed');
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
<span class="badge" id="track_badge">未选择</span>
|
<span class="badge" id="track_badge">未选择</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="track_select">选择音乐(来自 uploads/audio/general)</label>
|
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/general)</label>
|
||||||
<div class="row" style="margin-top:0">
|
<div class="row" style="margin-top:0">
|
||||||
<select id="track_select" onchange="onTrackChange()" style="flex:1; min-width: 200px;"></select>
|
<select id="track_select" onchange="onTrackChange()" style="flex:1; min-width: 200px;"></select>
|
||||||
<button class="btn" onclick="loadTracks()">刷新</button>
|
<button class="btn" onclick="loadTracks()">刷新</button>
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
sel.innerHTML = '<option value="">(无可用音频)</option>';
|
sel.innerHTML = '<option value="">(无可用音频)</option>';
|
||||||
onTrackChange();
|
onTrackChange();
|
||||||
setStatus("没有可用音频(请上传到 uploads/audio/general)");
|
setStatus("没有可用音频(请上传到 /opt/CC-StationBroadcast/Audio/general)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sel.appendChild(new Option("请选择…", ""));
|
sel.appendChild(new Option("请选择…", ""));
|
||||||
@@ -433,4 +433,3 @@
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hint" id="hint_area">
|
<div class="hint" id="hint_area">
|
||||||
需要服务端已存在 lsts.wav(建议放在 uploads/audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
需要服务端已存在 lsts.wav(建议放在 /opt/CC-StationBroadcast/Audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="preview_area" style="margin-top:12px; display:none;">
|
<div id="preview_area" style="margin-top:12px; display:none;">
|
||||||
|
|||||||
+65
-112
@@ -1,11 +1,4 @@
|
|||||||
/*
|
|
||||||
* @Author: HenryDu8133 813367384@qq.com
|
|
||||||
* @Date: 2026-02-24 14:40:25
|
|
||||||
* @LastEditors: HenryDu8133 813367384@qq.com
|
|
||||||
* @LastEditTime: 2026-07-31 19:29:12
|
|
||||||
* @FilePath: \StationBroadcastDisplayCN\cloudserver\server.js
|
|
||||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
@@ -35,6 +28,7 @@ const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8';
|
|||||||
const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
|
const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
|
||||||
const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0';
|
const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0';
|
||||||
|
|
||||||
|
const appRootDir = path.resolve(__dirname, '..');
|
||||||
const tmpDir = path.join(__dirname, 'tmp');
|
const tmpDir = path.join(__dirname, 'tmp');
|
||||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
|
const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
|
||||||
@@ -42,13 +36,15 @@ const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
|
|||||||
const localHlsDir = path.join(__dirname, 'public', 'hls');
|
const localHlsDir = path.join(__dirname, 'public', 'hls');
|
||||||
if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true });
|
if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true });
|
||||||
|
|
||||||
// Ensure upload directories exist
|
|
||||||
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
||||||
const audioGeneralDir = path.join(uploadDir, 'audio', 'general');
|
const audioRootDir = path.join(appRootDir, 'Audio');
|
||||||
const audioStationDir = path.join(uploadDir, 'audio', 'station');
|
const audioGeneralDir = path.join(audioRootDir, 'general');
|
||||||
const tempBroadcastDir = path.join(uploadDir, 'temp-broadcast');
|
const audioStationDir = path.join(audioRootDir, 'station');
|
||||||
|
const listFilePath = path.join(appRootDir, 'list.txt');
|
||||||
|
const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast');
|
||||||
|
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
if (!fs.existsSync(audioRootDir)) fs.mkdirSync(audioRootDir, { recursive: true });
|
||||||
if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true });
|
if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true });
|
||||||
if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
|
if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
|
||||||
if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
|
if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
|
||||||
@@ -57,7 +53,7 @@ if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
if (file.fieldname === 'listFile') {
|
if (file.fieldname === 'listFile') {
|
||||||
cb(null, uploadDir);
|
cb(null, appRootDir);
|
||||||
} else if (file.fieldname === 'generalAudioFiles') {
|
} else if (file.fieldname === 'generalAudioFiles') {
|
||||||
cb(null, audioGeneralDir);
|
cb(null, audioGeneralDir);
|
||||||
} else if (file.fieldname === 'stationAudioFiles') {
|
} else if (file.fieldname === 'stationAudioFiles') {
|
||||||
@@ -91,7 +87,6 @@ app.set('view engine', 'ejs');
|
|||||||
|
|
||||||
// Data Store (In-memory)
|
// Data Store (In-memory)
|
||||||
let stations = {};
|
let stations = {};
|
||||||
const stationRouteIndex = new Map();
|
|
||||||
let activeTrains = [];
|
let activeTrains = [];
|
||||||
let stationLogs = [];
|
let stationLogs = [];
|
||||||
let audioVersion = String(Date.now());
|
let audioVersion = String(Date.now());
|
||||||
@@ -479,15 +474,42 @@ function normalizeVersionString(input) {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveManagedPath(inputPath) {
|
||||||
|
const normalized = String(inputPath || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
if (normalized === 'list.txt' || normalized === 'uploads/list.txt') {
|
||||||
|
return { logicalPath: 'list.txt', fullPath: listFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixes = [
|
||||||
|
{ prefix: 'Audio/general/', baseDir: audioGeneralDir, logicalPrefix: 'Audio/general/' },
|
||||||
|
{ prefix: 'uploads/audio/general/', baseDir: audioGeneralDir, logicalPrefix: 'Audio/general/' },
|
||||||
|
{ prefix: 'Audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' },
|
||||||
|
{ prefix: 'uploads/audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of prefixes) {
|
||||||
|
if (!normalized.startsWith(entry.prefix)) continue;
|
||||||
|
const relativeName = normalized.slice(entry.prefix.length).trim();
|
||||||
|
if (!relativeName || relativeName.includes('/') || relativeName.includes('\\')) return null;
|
||||||
|
return {
|
||||||
|
logicalPath: `${entry.logicalPrefix}${relativeName}`,
|
||||||
|
fullPath: path.join(entry.baseDir, relativeName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getManagedFiles() {
|
function getManagedFiles() {
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
const configPath = path.join(uploadDir, 'list.txt');
|
if (fs.existsSync(listFilePath)) {
|
||||||
if (fs.existsSync(configPath)) {
|
const stats = fs.statSync(listFilePath);
|
||||||
const stats = fs.statSync(configPath);
|
|
||||||
files.push({
|
files.push({
|
||||||
name: 'list.txt',
|
name: 'list.txt',
|
||||||
path: 'uploads/list.txt',
|
path: 'list.txt',
|
||||||
type: 'config',
|
type: 'config',
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mtime: stats.mtimeMs
|
mtime: stats.mtimeMs
|
||||||
@@ -504,7 +526,7 @@ function getManagedFiles() {
|
|||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
files.push({
|
files.push({
|
||||||
name: file,
|
name: file,
|
||||||
path: `uploads/audio/general/${file}`,
|
path: `Audio/general/${file}`,
|
||||||
type: 'general',
|
type: 'general',
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mtime: stats.mtimeMs
|
mtime: stats.mtimeMs
|
||||||
@@ -522,7 +544,7 @@ function getManagedFiles() {
|
|||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
files.push({
|
files.push({
|
||||||
name: file,
|
name: file,
|
||||||
path: `uploads/audio/station/${file}`,
|
path: `Audio/station/${file}`,
|
||||||
type: 'station',
|
type: 'station',
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mtime: stats.mtimeMs
|
mtime: stats.mtimeMs
|
||||||
@@ -630,50 +652,15 @@ function normalizeStationPlatform(platform) {
|
|||||||
return value || '1';
|
return value || '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStationRouteKeys(name, code, platform) {
|
function normalizeStationRecord(station) {
|
||||||
const keys = [];
|
|
||||||
const normalizedCode = normalizeStationCode(code || getStationCodeFromName(name));
|
|
||||||
const normalizedPlatform = normalizeStationPlatform(platform || getPlatformNumberFromName(name));
|
|
||||||
const baseName = getBaseName(name);
|
|
||||||
|
|
||||||
if (normalizedCode) {
|
|
||||||
keys.push(`code:${normalizedCode}|platform:${normalizedPlatform}`);
|
|
||||||
keys.push(`code:${normalizedCode}`);
|
|
||||||
}
|
|
||||||
if (baseName) {
|
|
||||||
keys.push(`base:${baseName}|platform:${normalizedPlatform}`);
|
|
||||||
keys.push(`base:${baseName}`);
|
|
||||||
}
|
|
||||||
return [...new Set(keys)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function unindexStation(station) {
|
|
||||||
if (!station) return;
|
|
||||||
const keys = Array.isArray(station.routeKeys) ? station.routeKeys : [];
|
|
||||||
for (const key of keys) {
|
|
||||||
const current = stationRouteIndex.get(key);
|
|
||||||
if (current && current.name === station.name) {
|
|
||||||
stationRouteIndex.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
station.routeKeys = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexStation(station) {
|
|
||||||
if (!station || !station.name) return;
|
if (!station || !station.name) return;
|
||||||
unindexStation(station);
|
|
||||||
station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
|
station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
|
||||||
station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
|
station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
|
||||||
station.routeKeys = getStationRouteKeys(station.name, station.code, station.platform);
|
|
||||||
for (const key of station.routeKeys) {
|
|
||||||
stationRouteIndex.set(key, station);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshStationIndex() {
|
function refreshStationIndex() {
|
||||||
stationRouteIndex.clear();
|
|
||||||
for (const station of Object.values(stations)) {
|
for (const station of Object.values(stations)) {
|
||||||
indexStation(station);
|
normalizeStationRecord(station);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1270,7 +1257,7 @@ function handleHeartbeat(ws, data) {
|
|||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
ws: ws
|
ws: ws
|
||||||
};
|
};
|
||||||
indexStation(stations[cleanName]);
|
normalizeStationRecord(stations[cleanName]);
|
||||||
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`);
|
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`);
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
} else {
|
} else {
|
||||||
@@ -1282,7 +1269,7 @@ function handleHeartbeat(ws, data) {
|
|||||||
log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`);
|
log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`);
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
}
|
}
|
||||||
indexStation(stations[cleanName]);
|
normalizeStationRecord(stations[cleanName]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1298,7 +1285,7 @@ function handleStationRegister(ws, data) {
|
|||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
ws: ws // Keep reference if we need to send back
|
ws: ws // Keep reference if we need to send back
|
||||||
};
|
};
|
||||||
indexStation(stations[cleanName]);
|
normalizeStationRecord(stations[cleanName]);
|
||||||
|
|
||||||
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`);
|
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`);
|
||||||
|
|
||||||
@@ -1328,24 +1315,15 @@ function handleTrainArrival(data) {
|
|||||||
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
||||||
|
|
||||||
// Create history entry
|
// Create history entry
|
||||||
const historyEntry = {
|
|
||||||
station: stationName,
|
|
||||||
action: 'Arrival',
|
|
||||||
time: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
let trainData = {
|
let trainData = {
|
||||||
trainId,
|
trainId,
|
||||||
currentStation: stationName,
|
currentStation: stationName,
|
||||||
destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"),
|
destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"),
|
||||||
status: "At Station",
|
status: "At Station",
|
||||||
lastUpdate: Date.now(),
|
lastUpdate: Date.now(),
|
||||||
schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null),
|
schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null)
|
||||||
tripLogs: existingIndex >= 0 ? (activeTrains[existingIndex].tripLogs || []) : []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
trainData.tripLogs.push(historyEntry);
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData };
|
activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData };
|
||||||
} else {
|
} else {
|
||||||
@@ -1420,12 +1398,6 @@ function isFuzzyMatch(str, pattern) {
|
|||||||
function findStationByFlexibleName(targetName) {
|
function findStationByFlexibleName(targetName) {
|
||||||
if (!targetName) return null;
|
if (!targetName) return null;
|
||||||
|
|
||||||
const directKeys = getStationRouteKeys(targetName);
|
|
||||||
for (const key of directKeys) {
|
|
||||||
const station = stationRouteIndex.get(key);
|
|
||||||
if (station) return station;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Try exact match
|
// 1. Try exact match
|
||||||
if (stations[targetName]) return stations[targetName];
|
if (stations[targetName]) return stations[targetName];
|
||||||
|
|
||||||
@@ -1458,20 +1430,13 @@ function handleTrainDeparture(data) {
|
|||||||
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
const train = activeTrains[existingIndex];
|
const train = activeTrains[existingIndex];
|
||||||
train.status = "In Transit";
|
train.status = "Departed";
|
||||||
train.currentStation = "Transit (" + stationName + " -> " + (destination || "?") + ")";
|
train.currentStation = stationName;
|
||||||
train.lastUpdate = Date.now();
|
train.lastUpdate = Date.now();
|
||||||
train.lastStation = stationName;
|
train.lastStation = stationName;
|
||||||
train.departureTime = Date.now();
|
train.departureTime = Date.now();
|
||||||
train.destination = destination || train.destination;
|
train.destination = destination || train.destination;
|
||||||
train.schedule = schedule || train.schedule;
|
train.schedule = schedule || train.schedule;
|
||||||
|
|
||||||
if (!train.tripLogs) train.tripLogs = [];
|
|
||||||
train.tripLogs.push({
|
|
||||||
station: stationName,
|
|
||||||
action: 'Departure',
|
|
||||||
time: Date.now()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
}
|
}
|
||||||
@@ -1779,24 +1744,20 @@ app.delete('/api/files', (req, res) => {
|
|||||||
const filePath = req.query.path;
|
const filePath = req.query.path;
|
||||||
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
||||||
|
|
||||||
// Security check: prevent directory traversal
|
const resolved = resolveManagedPath(filePath);
|
||||||
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||||
const fullPath = path.join(__dirname, 'public', safePath);
|
const { fullPath, logicalPath } = resolved;
|
||||||
|
|
||||||
if (!fullPath.startsWith(uploadDir)) {
|
|
||||||
return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(fullPath)) {
|
if (fs.existsSync(fullPath)) {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(fullPath);
|
fs.unlinkSync(fullPath);
|
||||||
log('INFO', `Deleted file: ${safePath}`);
|
log('INFO', `Deleted file: ${logicalPath}`);
|
||||||
|
|
||||||
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
|
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
|
||||||
if (safePath.endsWith('.wav')) {
|
if (logicalPath.endsWith('.wav')) {
|
||||||
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
|
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
|
||||||
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
|
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
|
||||||
} else if (safePath.endsWith('.dfpwm')) {
|
} else if (logicalPath.endsWith('.dfpwm')) {
|
||||||
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
|
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
|
||||||
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
||||||
}
|
}
|
||||||
@@ -1828,7 +1789,7 @@ app.post('/api/stations', express.json(), (req, res) => {
|
|||||||
platform: getPlatformNumberFromName(name),
|
platform: getPlatformNumberFromName(name),
|
||||||
lastSeen: Date.now()
|
lastSeen: Date.now()
|
||||||
};
|
};
|
||||||
indexStation(stations[name]);
|
normalizeStationRecord(stations[name]);
|
||||||
saveData();
|
saveData();
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } });
|
res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } });
|
||||||
@@ -1846,7 +1807,6 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
|
|||||||
if (newName && newName !== name) {
|
if (newName && newName !== name) {
|
||||||
if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' });
|
if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' });
|
||||||
stations[newName] = { ...st, name: newName };
|
stations[newName] = { ...st, name: newName };
|
||||||
unindexStation(stations[name]);
|
|
||||||
delete stations[name];
|
delete stations[name];
|
||||||
targetName = newName;
|
targetName = newName;
|
||||||
}
|
}
|
||||||
@@ -1854,7 +1814,7 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
|
|||||||
if (nextCode !== undefined) stations[targetName].code = nextCode;
|
if (nextCode !== undefined) stations[targetName].code = nextCode;
|
||||||
stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name);
|
stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name);
|
||||||
stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now();
|
stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now();
|
||||||
indexStation(stations[targetName]);
|
normalizeStationRecord(stations[targetName]);
|
||||||
|
|
||||||
saveData();
|
saveData();
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
@@ -1864,7 +1824,6 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
|
|||||||
app.delete('/api/stations/:name', (req, res) => {
|
app.delete('/api/stations/:name', (req, res) => {
|
||||||
const name = req.params.name;
|
const name = req.params.name;
|
||||||
if (stations[name]) {
|
if (stations[name]) {
|
||||||
unindexStation(stations[name]);
|
|
||||||
delete stations[name];
|
delete stations[name];
|
||||||
log('INFO', `Deleted station: ${name}`);
|
log('INFO', `Deleted station: ${name}`);
|
||||||
saveData();
|
saveData();
|
||||||
@@ -1906,12 +1865,9 @@ app.get('/api/files/content', (req, res) => {
|
|||||||
const filePath = req.query.path;
|
const filePath = req.query.path;
|
||||||
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
||||||
|
|
||||||
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
const resolved = resolveManagedPath(filePath);
|
||||||
const fullPath = path.join(__dirname, 'public', safePath);
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||||
|
const { fullPath } = resolved;
|
||||||
if (!fullPath.startsWith(uploadDir)) {
|
|
||||||
return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(fullPath)) {
|
if (fs.existsSync(fullPath)) {
|
||||||
try {
|
try {
|
||||||
@@ -1929,16 +1885,13 @@ app.post('/api/files/content', express.json(), (req, res) => {
|
|||||||
const { path: filePath, content } = req.body;
|
const { path: filePath, content } = req.body;
|
||||||
if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' });
|
if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' });
|
||||||
|
|
||||||
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
const resolved = resolveManagedPath(filePath);
|
||||||
const fullPath = path.join(__dirname, 'public', safePath);
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||||
|
const { fullPath, logicalPath } = resolved;
|
||||||
if (!fullPath.startsWith(uploadDir)) {
|
|
||||||
return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(fullPath, content, 'utf8');
|
fs.writeFileSync(fullPath, content, 'utf8');
|
||||||
log('INFO', `Updated file: ${safePath}`);
|
log('INFO', `Updated file: ${logicalPath}`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ success: false, message: e.message });
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user