exflower-license-server 1.4.3 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/admin/index.html +65 -47
- package/index.js +27 -13
- package/package.json +1 -1
- package/routes.js +97 -55
package/README.md
CHANGED
|
@@ -135,16 +135,20 @@ docker run -d -p 4001:4001 \
|
|
|
135
135
|
```bash
|
|
136
136
|
cd license-server
|
|
137
137
|
npm install
|
|
138
|
-
npm start
|
|
138
|
+
ADMIN_TOKEN=your-secure-token npm start
|
|
139
139
|
```
|
|
140
140
|
|
|
141
141
|
## 管理员 Token 管理
|
|
142
142
|
|
|
143
|
-
###
|
|
143
|
+
### Token 必须显式配置(S5,2026-09-23 安全加固)
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
**未设置 `ADMIN_TOKEN`(或使用默认值/占位符)时服务直接拒绝启动。**
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
```bash
|
|
148
|
+
[FATAL] 未设置有效的 ADMIN_TOKEN(默认值/占位符不允许启动)。
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
推荐用 `ADMIN_TOKEN_HASH`(sha256 hex)代替明文 `ADMIN_TOKEN`:`echo -n "你的token" | shasum -a 256`。
|
|
148
152
|
|
|
149
153
|
### 临时修改(当前会话)
|
|
150
154
|
|
|
@@ -218,7 +222,7 @@ sudo systemctl enable --now exflower-license-server
|
|
|
218
222
|
| 变量 | 默认值 | 说明 |
|
|
219
223
|
|------|--------|------|
|
|
220
224
|
| `PORT` | `4001` | 服务端口 |
|
|
221
|
-
| `ADMIN_TOKEN` |
|
|
225
|
+
| `ADMIN_TOKEN` | **必填** | 管理后台鉴权 Token(默认值/占位符拒绝启动) |
|
|
222
226
|
| `ADMIN_TOKEN_HASH` | — | 管理 Token 的 sha256(hex)。设置后忽略 `ADMIN_TOKEN`,明文不落环境变量(`echo -n "你的token" \| shasum -a 256` 生成) |
|
|
223
227
|
| `LICENSE_DATA_DIR` | 见下方 | 数据库持久化目录 |
|
|
224
228
|
| `NODE_ENV` | `production` | 运行环境 |
|
|
@@ -285,7 +289,7 @@ sudo systemctl enable --now exflower-license-server
|
|
|
285
289
|
- 登录时真实校验 Token,错误 Token 无法进入后台
|
|
286
290
|
- 管理接口限速 60 次/分/IP,license 验证接口 120 次/分/IP(超限 429)
|
|
287
291
|
- Token 比较使用 timing-safe 方式;支持 `ADMIN_TOKEN_HASH` 哈希配置(明文不落环境变量)
|
|
288
|
-
-
|
|
292
|
+
- 未设置有效 `ADMIN_TOKEN` 时拒绝启动(默认值/占位符一律视为未设置);非 localhost 的 HTTP 访问显示明文传输警示 banner
|
|
289
293
|
|
|
290
294
|
## 白标品牌名
|
|
291
295
|
|
package/admin/index.html
CHANGED
|
@@ -475,6 +475,14 @@
|
|
|
475
475
|
<script>
|
|
476
476
|
let ADMIN_TOKEN = '';
|
|
477
477
|
|
|
478
|
+
// S1(2026-09-23 审查):动态值进 innerHTML 前一律 esc;onclick 只传服务端生成的
|
|
479
|
+
// id、展示名从缓存查——JS 字符串上下文 esc 救不了(双引号断属性、单引号断串)
|
|
480
|
+
function esc(s) {
|
|
481
|
+
return String(s ?? '')
|
|
482
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
483
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
484
|
+
}
|
|
485
|
+
|
|
478
486
|
// 登录真校验:token 必须能通过一次真实 admin 请求,否则拒绝进入
|
|
479
487
|
async function ensureLogin() {
|
|
480
488
|
while (true) {
|
|
@@ -622,16 +630,16 @@
|
|
|
622
630
|
const fmt = fmtTime;
|
|
623
631
|
const rows = [
|
|
624
632
|
['状态', `<span class="badge ${cls}">${label}</span>`],
|
|
625
|
-
['客户', a.customer || '—'],
|
|
626
|
-
['Key 前缀', a.key_prefix ? `<code>${a.key_prefix}</code>` : '—'],
|
|
627
|
-
['服务器指纹', a.fingerprint ? `<code>${a.fingerprint.slice(0, 13)}…</code>` : '—'],
|
|
633
|
+
['客户', esc(a.customer) || '—'],
|
|
634
|
+
['Key 前缀', a.key_prefix ? `<code>${esc(a.key_prefix)}</code>` : '—'],
|
|
635
|
+
['服务器指纹', a.fingerprint ? `<code>${esc(String(a.fingerprint).slice(0, 13))}…</code>` : '—'],
|
|
628
636
|
['合同到期', a.expires_at ? `${fmtDate(a.expires_at)}(剩 ${a.days_remaining} 天)` : '—'],
|
|
629
637
|
['凭证有效期至', fmt(a.token_expires_at)],
|
|
630
638
|
['实例配额(中央)', a.instance_quota ? `总额度 ${a.instance_quota.limit},已分配 ${a.instance_quota.used},剩余 ${a.instance_quota.remaining}` : ((a.limits && a.limits.max_instances) || '—')],
|
|
631
|
-
['主端点', `<code>${a.endpoints ? a.endpoints.primary : ''}</code>`],
|
|
632
|
-
['备端点', a.endpoints && a.endpoints.backup ? `<code>${a.endpoints.backup}</code>` : '—'],
|
|
639
|
+
['主端点', `<code>${esc(a.endpoints ? a.endpoints.primary : '')}</code>`],
|
|
640
|
+
['备端点', a.endpoints && a.endpoints.backup ? `<code>${esc(a.endpoints.backup)}</code>` : '—'],
|
|
633
641
|
['最近复核', fmt(a.last_revalidate)],
|
|
634
|
-
['最近错误', a.last_error ? `<span style="color:#c62828">${a.last_error}</span>` : '—'],
|
|
642
|
+
['最近错误', a.last_error ? `<span style="color:#c62828">${esc(a.last_error)}</span>` : '—'],
|
|
635
643
|
];
|
|
636
644
|
document.getElementById('act-status').innerHTML =
|
|
637
645
|
'<table><tbody>' + rows.map(r => `<tr><th style="width:140px;text-align:left">${r[0]}</th><td>${r[1]}</td></tr>`).join('') + '</tbody></table>';
|
|
@@ -668,9 +676,11 @@
|
|
|
668
676
|
}
|
|
669
677
|
|
|
670
678
|
async function loadStats() {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
679
|
+
// S12(2026-09-23 审查):/health 不再对外回运营计数,统计改走 admin 实例接口
|
|
680
|
+
const res = await api('GET', '/api/admin/instances');
|
|
681
|
+
const st = res.stats || {};
|
|
682
|
+
document.getElementById('stat-companies').textContent = st.total_companies ?? 0;
|
|
683
|
+
document.getElementById('stat-instances').textContent = st.active_instances ?? 0;
|
|
674
684
|
}
|
|
675
685
|
|
|
676
686
|
// ── License 管理 ──
|
|
@@ -701,7 +711,7 @@
|
|
|
701
711
|
|
|
702
712
|
let expiresHtml = '永久';
|
|
703
713
|
if (c.expires_at) {
|
|
704
|
-
const dateStr = String(c.expires_at).slice(0, 10); // 纯日期串(管理员录入),不做时区换算
|
|
714
|
+
const dateStr = esc(String(c.expires_at).slice(0, 10)); // 纯日期串(管理员录入),不做时区换算
|
|
705
715
|
// 到期日按本地当日 23:59:59 算剩余天数,避免 UTC 午夜解析差一天
|
|
706
716
|
const [ey, em, ed] = dateStr.split('-').map(Number);
|
|
707
717
|
const daysLeft = Math.ceil((new Date(ey, em - 1, ed, 23, 59, 59) - Date.now()) / 86400000);
|
|
@@ -715,8 +725,8 @@
|
|
|
715
725
|
}
|
|
716
726
|
|
|
717
727
|
tr.innerHTML = `
|
|
718
|
-
<td><strong>${c.name}</strong></td>
|
|
719
|
-
<td><span class="license-key">${c.license_key}</span></td>
|
|
728
|
+
<td><strong>${esc(c.name)}</strong></td>
|
|
729
|
+
<td><span class="license-key">${esc(c.license_key)}</span></td>
|
|
720
730
|
<td>${c.max_instances}实例 / ${c.max_agents}Agent / ${c.max_crews}项目组</td>
|
|
721
731
|
<td><span class="badge ${finalClass}">${finalStatus}</span></td>
|
|
722
732
|
<td>${expiresHtml}</td>
|
|
@@ -726,7 +736,7 @@
|
|
|
726
736
|
<button class="btn-sm ${c.status === 'active' ? 'btn-plain' : ''}" onclick="toggleStatus('${c.id}', '${c.status}')">
|
|
727
737
|
${c.status === 'active' ? '暂停' : '恢复'}
|
|
728
738
|
</button>
|
|
729
|
-
<button class="btn-sm btn-plain" onclick="resetKey('${c.id}'
|
|
739
|
+
<button class="btn-sm btn-plain" onclick="resetKey('${c.id}')">重置 Key</button>
|
|
730
740
|
<button class="btn-sm btn-danger" onclick="deleteCompany('${c.id}')">删除</button>
|
|
731
741
|
</td>
|
|
732
742
|
`;
|
|
@@ -746,10 +756,10 @@
|
|
|
746
756
|
const hbColor = daysSince > 3 ? '#d32f2f' : daysSince > 1 ? '#ef6c00' : '#2e7d32';
|
|
747
757
|
|
|
748
758
|
tr.innerHTML = `
|
|
749
|
-
<td>${i.company_name}</td>
|
|
750
|
-
<td><code>${i.instance_id}</code></td>
|
|
751
|
-
<td>${i.hostname || '-'}</td>
|
|
752
|
-
<td>${i.version || '-'}</td>
|
|
759
|
+
<td>${esc(i.company_name)}</td>
|
|
760
|
+
<td><code>${esc(i.instance_id)}</code></td>
|
|
761
|
+
<td>${esc(i.hostname) || '-'}</td>
|
|
762
|
+
<td>${esc(i.version) || '-'}</td>
|
|
753
763
|
<td>${i.first_seen ? fmtDate(i.first_seen) : '-'}</td>
|
|
754
764
|
<td style="color:${hbColor}">${i.last_heartbeat ? fmtTime(i.last_heartbeat) : '-'}</td>
|
|
755
765
|
<td><button class="btn-sm btn-danger" onclick="deleteInstance('${i.id}')">删除</button></td>
|
|
@@ -801,7 +811,8 @@
|
|
|
801
811
|
document.getElementById('key-modal').classList.add('show');
|
|
802
812
|
}
|
|
803
813
|
|
|
804
|
-
async function resetKey(id
|
|
814
|
+
async function resetKey(id) {
|
|
815
|
+
const name = companiesCache.find(x => x.id === id)?.name || id;
|
|
805
816
|
if (!confirm(`确定重置「${name}」的激活码?\n\n旧激活码将立即失效,使用该激活码的客户端会失去授权,需要重新激活。`)) return;
|
|
806
817
|
const res = await api('POST', `/api/admin/companies/${id}/reset-key`);
|
|
807
818
|
if (res.success && res.license_key) {
|
|
@@ -975,13 +986,13 @@
|
|
|
975
986
|
const c = categoriesCache[i];
|
|
976
987
|
const tr = document.createElement('tr');
|
|
977
988
|
tr.innerHTML = `
|
|
978
|
-
<td><strong>${c.name}</strong></td>
|
|
989
|
+
<td><strong>${esc(c.name)}</strong></td>
|
|
979
990
|
<td>${c.template_count}</td>
|
|
980
991
|
<td>
|
|
981
992
|
<button class="btn-sm btn-plain" ${i === 0 ? 'disabled' : ''} onclick="moveCategory('${c.id}', -1)" title="上移">↑</button>
|
|
982
993
|
<button class="btn-sm btn-plain" ${i === categoriesCache.length - 1 ? 'disabled' : ''} onclick="moveCategory('${c.id}', 1)" title="下移">↓</button>
|
|
983
994
|
<button class="btn-sm btn-plain" onclick="renameCategory('${c.id}')">改名</button>
|
|
984
|
-
<button class="btn-sm btn-danger" onclick="deleteCategory('${c.id}'
|
|
995
|
+
<button class="btn-sm btn-danger" onclick="deleteCategory('${c.id}')">删除</button>
|
|
985
996
|
</td>
|
|
986
997
|
`;
|
|
987
998
|
tbody.appendChild(tr);
|
|
@@ -1025,7 +1036,8 @@
|
|
|
1025
1036
|
renderCategoryList();
|
|
1026
1037
|
}
|
|
1027
1038
|
|
|
1028
|
-
async function deleteCategory(id
|
|
1039
|
+
async function deleteCategory(id) {
|
|
1040
|
+
const name = categoriesCache.find(c => c.id === id)?.name || id;
|
|
1029
1041
|
if (!confirm(`确定删除分类「${name}」?挂在该分类下的模板将回落为未分类。`)) return;
|
|
1030
1042
|
await api('DELETE', `/api/admin/template-categories/${id}`);
|
|
1031
1043
|
toast('已删除');
|
|
@@ -1067,19 +1079,19 @@
|
|
|
1067
1079
|
card.title = `创建于 ${t.created_at || '-'}`;
|
|
1068
1080
|
card.innerHTML = `
|
|
1069
1081
|
<div class="card-head">
|
|
1070
|
-
<h3>${t.name}</h3>
|
|
1071
|
-
<span class="ver">v${t.version || '1.0'}</span>
|
|
1082
|
+
<h3>${esc(t.name)}</h3>
|
|
1083
|
+
<span class="ver">v${esc(t.version || '1.0')}</span>
|
|
1072
1084
|
</div>
|
|
1073
|
-
<p class="desc">${t.description
|
|
1085
|
+
<p class="desc">${esc(t.description)}</p>
|
|
1074
1086
|
<div class="chips">
|
|
1075
|
-
${t.category ? `<span class="chip">${t.category}</span>` : ''}
|
|
1076
|
-
${companiesChip(t) ? `<span class="chip chip-neutral">${companiesChip(t)}</span>` : ''}
|
|
1087
|
+
${t.category ? `<span class="chip">${esc(t.category)}</span>` : ''}
|
|
1088
|
+
${companiesChip(t) ? `<span class="chip chip-neutral">${esc(companiesChip(t))}</span>` : ''}
|
|
1077
1089
|
</div>
|
|
1078
1090
|
<div class="card-foot">
|
|
1079
1091
|
<span class="foot-meta">导入 ${importCount} 次</span>
|
|
1080
1092
|
<div class="actions">
|
|
1081
1093
|
<button class="act" onclick="editTemplate('${t.id}')">编辑</button>
|
|
1082
|
-
<button class="act" onclick="showImportDetails('${t.id}'
|
|
1094
|
+
<button class="act" onclick="showImportDetails('${t.id}')">导入记录</button>
|
|
1083
1095
|
<button class="act act-danger" onclick="deleteTemplate('${t.id}')">删除</button>
|
|
1084
1096
|
</div>
|
|
1085
1097
|
</div>
|
|
@@ -1099,7 +1111,7 @@
|
|
|
1099
1111
|
box.appendChild(all);
|
|
1100
1112
|
for (const c of res.companies || []) {
|
|
1101
1113
|
const label = document.createElement('label');
|
|
1102
|
-
label.innerHTML = `<input type="checkbox" value="${c.id}" checked><span>${c.name}</span>`;
|
|
1114
|
+
label.innerHTML = `<input type="checkbox" value="${c.id}" checked><span>${esc(c.name)}</span>`;
|
|
1103
1115
|
box.appendChild(label);
|
|
1104
1116
|
}
|
|
1105
1117
|
// 联动:全选 ↔ 单项
|
|
@@ -1440,7 +1452,7 @@
|
|
|
1440
1452
|
loadTemplates();
|
|
1441
1453
|
}
|
|
1442
1454
|
|
|
1443
|
-
async function showImportDetails(templateId
|
|
1455
|
+
async function showImportDetails(templateId) {
|
|
1444
1456
|
const res = await api('GET', `/api/admin/imports?template_id=${encodeURIComponent(templateId)}`);
|
|
1445
1457
|
const tbody = document.getElementById('import-detail-body');
|
|
1446
1458
|
tbody.innerHTML = '';
|
|
@@ -1452,16 +1464,16 @@
|
|
|
1452
1464
|
for (const imp of imports) {
|
|
1453
1465
|
const tr = document.createElement('tr');
|
|
1454
1466
|
tr.innerHTML = `
|
|
1455
|
-
<td>${imp.company_name || '-'}</td>
|
|
1456
|
-
<td><code>${imp.instance_id || '-'}</code></td>
|
|
1457
|
-
<td>${imp.template_name || '-'}</td>
|
|
1467
|
+
<td>${esc(imp.company_name) || '-'}</td>
|
|
1468
|
+
<td><code>${esc(imp.instance_id) || '-'}</code></td>
|
|
1469
|
+
<td>${esc(imp.template_name) || '-'}</td>
|
|
1458
1470
|
<td>${imp.imported_at ? fmtTime(imp.imported_at) : '-'}</td>
|
|
1459
1471
|
`;
|
|
1460
1472
|
tbody.appendChild(tr);
|
|
1461
1473
|
}
|
|
1462
1474
|
}
|
|
1463
1475
|
|
|
1464
|
-
document.getElementById('import-detail-title').textContent = `「${
|
|
1476
|
+
document.getElementById('import-detail-title').textContent = `「${imports[0]?.template_name || templateId}」导入记录 (${imports.length}次)`;
|
|
1465
1477
|
document.getElementById('import-detail-modal').classList.add('show');
|
|
1466
1478
|
}
|
|
1467
1479
|
|
|
@@ -1493,15 +1505,16 @@
|
|
|
1493
1505
|
return;
|
|
1494
1506
|
}
|
|
1495
1507
|
const platformLabel = { 'mac-arm64': 'macOS arm64', 'mac-x64': 'macOS Intel', 'win': 'Windows' };
|
|
1508
|
+
updatesCache = rows;
|
|
1496
1509
|
body.innerHTML = rows.map(u => `
|
|
1497
1510
|
<tr>
|
|
1498
|
-
<td>${platformLabel[u.platform] || u.platform}</td>
|
|
1499
|
-
<td>${u.version}</td>
|
|
1500
|
-
<td style="font-family:monospace;font-size:12px;">${u.file_name}</td>
|
|
1511
|
+
<td>${esc(platformLabel[u.platform] || u.platform)}</td>
|
|
1512
|
+
<td>${esc(u.version)}</td>
|
|
1513
|
+
<td style="font-family:monospace;font-size:12px;">${esc(u.file_name)}</td>
|
|
1501
1514
|
<td>${fmtPkgSize(u.file_size)}</td>
|
|
1502
1515
|
<td>${u.is_latest ? '✅' : ''}</td>
|
|
1503
1516
|
<td>${u.created_at ? fmtDate(u.created_at) : '-'}</td>
|
|
1504
|
-
<td><button class="btn-sm btn-danger" onclick="deleteUpdate('${u.id}'
|
|
1517
|
+
<td><button class="btn-sm btn-danger" onclick="deleteUpdate('${u.id}')">删除</button></td>
|
|
1505
1518
|
</tr>
|
|
1506
1519
|
`).join('');
|
|
1507
1520
|
}
|
|
@@ -1539,7 +1552,11 @@
|
|
|
1539
1552
|
}
|
|
1540
1553
|
}
|
|
1541
1554
|
|
|
1542
|
-
|
|
1555
|
+
let updatesCache = [];
|
|
1556
|
+
|
|
1557
|
+
async function deleteUpdate(id) {
|
|
1558
|
+
const u = updatesCache.find(x => x.id === id);
|
|
1559
|
+
const label = u ? `${u.version} (${u.platform})` : id;
|
|
1543
1560
|
if (!confirm(`确定删除更新包「${label}」?`)) return;
|
|
1544
1561
|
await api('DELETE', `/api/admin/updates/${id}`);
|
|
1545
1562
|
toast('已删除');
|
|
@@ -1574,22 +1591,22 @@
|
|
|
1574
1591
|
return;
|
|
1575
1592
|
}
|
|
1576
1593
|
grid.innerHTML = pkgs.map(p => `
|
|
1577
|
-
<div class="tpl-card" title="上传于 ${p.created_at || '-'}">
|
|
1594
|
+
<div class="tpl-card" title="上传于 ${esc(p.created_at) || '-'}">
|
|
1578
1595
|
<div class="card-head">
|
|
1579
|
-
<h3>${p.name}</h3>
|
|
1580
|
-
<span class="ver">v${p.version || '1.0'}</span>
|
|
1596
|
+
<h3>${esc(p.name)}</h3>
|
|
1597
|
+
<span class="ver">v${esc(p.version || '1.0')}</span>
|
|
1581
1598
|
</div>
|
|
1582
|
-
<p class="desc">${p.description
|
|
1599
|
+
<p class="desc">${esc(p.description)}</p>
|
|
1583
1600
|
<div class="chips">
|
|
1584
|
-
${p.category ? `<span class="chip">${p.category}</span>` : ''}
|
|
1585
|
-
${companiesChip(p) ? `<span class="chip chip-neutral">${companiesChip(p)}</span>` : ''}
|
|
1601
|
+
${p.category ? `<span class="chip">${esc(p.category)}</span>` : ''}
|
|
1602
|
+
${companiesChip(p) ? `<span class="chip chip-neutral">${esc(companiesChip(p))}</span>` : ''}
|
|
1586
1603
|
</div>
|
|
1587
1604
|
<div class="card-foot">
|
|
1588
1605
|
<span class="foot-meta">${fmtPkgSize(p.file_size)} · 导入 ${importCounts[p.id] || 0} 次</span>
|
|
1589
1606
|
<div class="actions">
|
|
1590
1607
|
<button class="act" onclick="editPackage('${p.id}')">编辑</button>
|
|
1591
|
-
<button class="act" onclick="showImportDetails('${p.id}'
|
|
1592
|
-
<button class="act act-danger" onclick="deletePackage('${p.id}'
|
|
1608
|
+
<button class="act" onclick="showImportDetails('${p.id}')">导入记录</button>
|
|
1609
|
+
<button class="act act-danger" onclick="deletePackage('${p.id}')">删除</button>
|
|
1593
1610
|
</div>
|
|
1594
1611
|
</div>
|
|
1595
1612
|
</div>
|
|
@@ -1680,7 +1697,8 @@
|
|
|
1680
1697
|
}
|
|
1681
1698
|
}
|
|
1682
1699
|
|
|
1683
|
-
async function deletePackage(id
|
|
1700
|
+
async function deletePackage(id) {
|
|
1701
|
+
const name = packagesCache.find(x => x.id === id)?.name || id;
|
|
1684
1702
|
if (!confirm(`确定删除轻应用「${name}」?客户端将无法再下载。`)) return;
|
|
1685
1703
|
await api('DELETE', `/api/admin/packages/${id}`);
|
|
1686
1704
|
toast('已删除');
|
package/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 环境变量:
|
|
7
7
|
* PORT - 服务端口 (默认 4001)
|
|
8
|
-
* ADMIN_TOKEN - 管理后台鉴权 Token
|
|
8
|
+
* ADMIN_TOKEN - 管理后台鉴权 Token(必设;默认值/占位符拒绝启动)
|
|
9
9
|
* ADMIN_TOKEN_HASH - 管理 Token 的 sha256(hex),设置后忽略 ADMIN_TOKEN(不落明文)
|
|
10
10
|
*
|
|
11
11
|
* 启动:
|
|
@@ -31,9 +31,31 @@ const PORT = process.env.PORT || 4001;
|
|
|
31
31
|
const DEFAULT_ADMIN_TOKEN = 'exflower-admin-2026';
|
|
32
32
|
const ADMIN_TOKEN_HASH = (process.env.ADMIN_TOKEN_HASH || '').trim();
|
|
33
33
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || DEFAULT_ADMIN_TOKEN;
|
|
34
|
-
const usingDefaultToken = !ADMIN_TOKEN_HASH && ADMIN_TOKEN === DEFAULT_ADMIN_TOKEN;
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
// S2(2026-09-23 审查):限流键此前在反代后取到 caddy 的 IP,全体流量共享一个桶。
|
|
36
|
+
// trust proxy 1 = 只信第一跳反代(宿主 caddy)的 X-Forwarded-For;
|
|
37
|
+
// 无反代直连形态(AO 类)没有 XFF,req.ip 仍是 socket 地址,行为不变
|
|
38
|
+
app.set('trust proxy', 1);
|
|
39
|
+
|
|
40
|
+
// S5(2026-09-23 审查):默认/占位 token 都是仓库内公开字符串,等于无鉴权。
|
|
41
|
+
// 未配置强凭证直接拒绝启动(部署检查单:升级镜像前逐实例核实 env)
|
|
42
|
+
const PLACEHOLDER_TOKENS = [DEFAULT_ADMIN_TOKEN, 'change-me-to-a-secure-token'];
|
|
43
|
+
if (!ADMIN_TOKEN_HASH && (!ADMIN_TOKEN || PLACEHOLDER_TOKENS.includes(ADMIN_TOKEN))) {
|
|
44
|
+
console.error('[FATAL] 未设置有效的 ADMIN_TOKEN(默认值/占位符不允许启动)。');
|
|
45
|
+
console.error(' 请设置 ADMIN_TOKEN 或 ADMIN_TOKEN_HASH(sha256 hex)环境变量后重启。');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// S11(2026-09-23 审查):cors() = 反射任意 origin。消费方是 Electron 客户端
|
|
50
|
+
// (Node fetch,无 Origin 头)+ 同源 admin 页;只放行本机开发 origin,其余不给
|
|
51
|
+
// ACAO 头(浏览器侧读不到响应,非浏览器请求不受影响)
|
|
52
|
+
app.use(cors({
|
|
53
|
+
origin: (origin, cb) => {
|
|
54
|
+
if (!origin) return cb(null, true);
|
|
55
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return cb(null, true);
|
|
56
|
+
cb(null, false);
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
37
59
|
app.use(express.json());
|
|
38
60
|
|
|
39
61
|
// Admin dashboard (static HTML)
|
|
@@ -58,22 +80,14 @@ require('./activation').startScheduler();
|
|
|
58
80
|
require('./updateSync').startUpdateSync();
|
|
59
81
|
|
|
60
82
|
app.listen(PORT, () => {
|
|
83
|
+
// S6(2026-09-23 审查):横幅不再打明文 token(容器日志多方可见),只回掩码
|
|
61
84
|
const tokenLine = ADMIN_TOKEN_HASH
|
|
62
85
|
? 'Admin Token: (hashed via ADMIN_TOKEN_HASH)'
|
|
63
|
-
: `Admin Token: ${ADMIN_TOKEN}
|
|
86
|
+
: `Admin Token: ${ADMIN_TOKEN.slice(0, 3)}****`;
|
|
64
87
|
console.log(`╔══════════════════════════════════════════════════╗`);
|
|
65
88
|
console.log(`║ ExFlower License Server ║`);
|
|
66
89
|
console.log(`║ Port: ${PORT.toString().padEnd(42)} ║`);
|
|
67
90
|
console.log(`║ Admin Dashboard: http://localhost:${PORT}/admin ║`);
|
|
68
91
|
console.log(`║ ${tokenLine.padEnd(48)} ║`);
|
|
69
92
|
console.log(`╚══════════════════════════════════════════════════╝`);
|
|
70
|
-
if (usingDefaultToken) {
|
|
71
|
-
console.warn('');
|
|
72
|
-
console.warn('╔══════════════════════════════════════════════════╗');
|
|
73
|
-
console.warn('║ ⚠️ 警告:正在使用默认 ADMIN_TOKEN! ║');
|
|
74
|
-
console.warn('║ 生产环境必须设置 ADMIN_TOKEN 或 ADMIN_TOKEN_HASH ║');
|
|
75
|
-
console.warn('║ WARNING: default admin token in use! ║');
|
|
76
|
-
console.warn('╚══════════════════════════════════════════════════╝');
|
|
77
|
-
console.warn('');
|
|
78
|
-
}
|
|
79
93
|
});
|
package/package.json
CHANGED
package/routes.js
CHANGED
|
@@ -13,6 +13,15 @@ const activation = require('./activation');
|
|
|
13
13
|
const { ensureFresh } = require('./updateSync');
|
|
14
14
|
|
|
15
15
|
const DEFAULT_ADMIN_TOKEN = 'exflower-admin-2026';
|
|
16
|
+
|
|
17
|
+
// S1(2026-09-23 审查):客户端上报的 hostname/version/instance_id 直接落库且
|
|
18
|
+
// admin 页 innerHTML 直出,恶意值即存储型 XSS(毒 hostname → admin 浏览器执行 →
|
|
19
|
+
// 窃 ADMIN_TOKEN → 传带毒更新包给全部客户)。入库白名单净化,非法整体置 '-'
|
|
20
|
+
const _SAFE_REPORT = /^[A-Za-z0-9._-]+$/;
|
|
21
|
+
function sanitizeReport(v, max = 64) {
|
|
22
|
+
const s = String(v == null ? '' : v).trim();
|
|
23
|
+
return (s && s.length <= max && _SAFE_REPORT.test(s)) ? s : '-';
|
|
24
|
+
}
|
|
16
25
|
// ADMIN_TOKEN_HASH(sha256 hex)优先;否则用明文 ADMIN_TOKEN。二选一。
|
|
17
26
|
const ADMIN_TOKEN_HASH = (process.env.ADMIN_TOKEN_HASH || '').trim().toLowerCase();
|
|
18
27
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || DEFAULT_ADMIN_TOKEN;
|
|
@@ -48,17 +57,27 @@ function rateLimit({ windowMs, max }) {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
// x-license-key → company(客户端端点共用)
|
|
60
|
+
// S3(2026-09-23 审查):被暂停/已过期的激活码此前仍可拉模板、轻应用包和客户端
|
|
61
|
+
// 更新——补齐与 verify/heartbeat 相同的拒绝语义
|
|
51
62
|
function companyByLicenseKey(req, res) {
|
|
52
63
|
const licenseKey = req.headers['x-license-key'];
|
|
53
64
|
if (!licenseKey) {
|
|
54
65
|
res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
55
66
|
return null;
|
|
56
67
|
}
|
|
57
|
-
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
68
|
+
const company = queryGet('SELECT id, status, expires_at FROM companies WHERE license_key = ?', [licenseKey]);
|
|
58
69
|
if (!company) {
|
|
59
70
|
res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
60
71
|
return null;
|
|
61
72
|
}
|
|
73
|
+
if (company.status === 'suspended') {
|
|
74
|
+
res.status(403).json({ success: false, message: 'License suspended' });
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (company.expires_at && new Date(company.expires_at) < new Date()) {
|
|
78
|
+
res.status(403).json({ success: false, message: 'License expired' });
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
62
81
|
return company;
|
|
63
82
|
}
|
|
64
83
|
|
|
@@ -113,6 +132,18 @@ async function readExfappManifest(filePath) {
|
|
|
113
132
|
return JSON.parse(buf.toString('utf8'));
|
|
114
133
|
}
|
|
115
134
|
|
|
135
|
+
// S9(2026-09-23 审查):上传包哈希此前 readFileSync 整读进内存——GB 级包在弱机
|
|
136
|
+
// OOM、>4GB 超 Buffer 上限直接 500。改流式分块哈希,算法/编码由调用方指定
|
|
137
|
+
function hashFile(filePath, algo, encoding) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const hash = crypto.createHash(algo);
|
|
140
|
+
fs.createReadStream(filePath)
|
|
141
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
142
|
+
.on('error', reject)
|
|
143
|
+
.on('end', () => resolve(hash.digest(encoding)));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
116
147
|
// 资料图谱模板上传(.exflib zip:manifest.json{format:'exflower-library'} + libraries/ + libraries-dbrows.json)
|
|
117
148
|
const LIBTPL_DIR = path.join(DATA_DIR, 'library-templates');
|
|
118
149
|
const libraryUpload = multer({
|
|
@@ -160,10 +191,12 @@ function buildLatestYml(mac) {
|
|
|
160
191
|
// 多实例的关键防线);本地文件仅作无 remote_url 时的兜底(离线/气隙场景)。
|
|
161
192
|
// 服务器仍后台拉包,本地副本是 CDN 不可达时的最终兜底(files/ 路由 302 已覆盖)。
|
|
162
193
|
const fileUrl = (r) => {
|
|
163
|
-
if (r.remote_url) return r.remote_url;
|
|
194
|
+
if (r.remote_url) return esc(r.remote_url);
|
|
164
195
|
return `files/${r.platform}/${esc(r.file_name)}`;
|
|
165
196
|
};
|
|
166
|
-
|
|
197
|
+
// S14(2026-09-23 审查):version 原样裸插值——值里带换行/引号可注入 YAML 改写
|
|
198
|
+
// 客户端升级目标。统一引号包裹+单引号转义(入口另有 semver 校验,这里兜底存量行)
|
|
199
|
+
const lines = [`version: '${esc(rows[0].version)}'`, 'files:'];
|
|
167
200
|
for (const r of rows) {
|
|
168
201
|
lines.push(` - url: '${fileUrl(r)}'`);
|
|
169
202
|
lines.push(` sha512: '${r.sha512}'`);
|
|
@@ -187,6 +220,10 @@ function setupRoutes(app) {
|
|
|
187
220
|
// 防爆破限速:admin 接口与 license 验证接口按 IP 限速
|
|
188
221
|
app.use('/api/admin', rateLimit({ windowMs: 60_000, max: 60 }));
|
|
189
222
|
app.use('/api/license', rateLimit({ windowMs: 60_000, max: 120 }));
|
|
223
|
+
// S4(2026-09-23 审查):内容三棵树此前完全无限流——持 key 者可无限探测 id、
|
|
224
|
+
// 打满带宽。常规树 120/min(正常客户端低频),下载路由更严 30/min
|
|
225
|
+
app.use(['/api/templates', '/api/packages', '/api/updates'], rateLimit({ windowMs: 60_000, max: 120 }));
|
|
226
|
+
app.use(['/api/templates/:id/download', '/api/packages/:id/download', '/api/updates/files'], rateLimit({ windowMs: 60_000, max: 30 }));
|
|
190
227
|
|
|
191
228
|
// ── 中央激活门禁:未激活/被顶替/过期 = 拒绝全部客户端服务(admin 端点不受影响) ──
|
|
192
229
|
app.use(['/api/license', '/api/templates', '/api/packages', '/api/updates'], (req, res, next) => {
|
|
@@ -216,7 +253,9 @@ function setupRoutes(app) {
|
|
|
216
253
|
}
|
|
217
254
|
res.json({ success: true, activation: st });
|
|
218
255
|
} catch (e) {
|
|
219
|
-
|
|
256
|
+
// S13(2026-09-23 审查):500 不直出内部错误细节,统一文案,详情进日志
|
|
257
|
+
console.error('[LicenseServer] Activation configure failed:', e.message);
|
|
258
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
220
259
|
}
|
|
221
260
|
});
|
|
222
261
|
|
|
@@ -225,7 +264,8 @@ function setupRoutes(app) {
|
|
|
225
264
|
const r = await activation.revalidateNow();
|
|
226
265
|
res.json({ success: !!r.ok, message: r.ok ? '复核成功' : (r.message || r.error), activation: activation.getStatus() });
|
|
227
266
|
} catch (e) {
|
|
228
|
-
|
|
267
|
+
console.error('[LicenseServer] Activation retry failed:', e.message);
|
|
268
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
229
269
|
}
|
|
230
270
|
});
|
|
231
271
|
|
|
@@ -236,7 +276,11 @@ function setupRoutes(app) {
|
|
|
236
276
|
|
|
237
277
|
// ── License Verification ──
|
|
238
278
|
app.post('/api/license/verify', (req, res) => {
|
|
239
|
-
const { license_key,
|
|
279
|
+
const { license_key, ip } = req.body;
|
|
280
|
+
// S1:上报字段净化要放在查表/落库之前(instance_id 也是查键,先净化保证 upsert 两端一致)
|
|
281
|
+
const instance_id = sanitizeReport(req.body?.instance_id);
|
|
282
|
+
const hostname = sanitizeReport(req.body?.hostname, 128);
|
|
283
|
+
const version = sanitizeReport(req.body?.version, 32);
|
|
240
284
|
|
|
241
285
|
if (!license_key || !instance_id) {
|
|
242
286
|
return res.status(400).json({
|
|
@@ -523,7 +567,13 @@ function setupRoutes(app) {
|
|
|
523
567
|
JOIN companies c ON i.company_id = c.id
|
|
524
568
|
ORDER BY i.last_heartbeat DESC
|
|
525
569
|
`);
|
|
526
|
-
|
|
570
|
+
// S12(2026-09-23 审查):原 /health 无认证公开的运营计数挪到这里(admin 鉴权后)
|
|
571
|
+
const stats = queryGet(`
|
|
572
|
+
SELECT
|
|
573
|
+
(SELECT COUNT(*) FROM companies) as total_companies,
|
|
574
|
+
(SELECT COUNT(*) FROM instances WHERE status = 'active' AND last_heartbeat > datetime('now', '-7 days')) as active_instances
|
|
575
|
+
`);
|
|
576
|
+
res.json({ success: true, instances, stats });
|
|
527
577
|
});
|
|
528
578
|
|
|
529
579
|
app.delete('/api/admin/instances/:id', requireAdmin, (req, res) => {
|
|
@@ -559,15 +609,8 @@ function setupRoutes(app) {
|
|
|
559
609
|
const setPackageCompanies = (id, ids) => setShareCompanies('package_companies', 'package_id', id, ids);
|
|
560
610
|
|
|
561
611
|
app.get('/api/templates', (req, res) => {
|
|
562
|
-
const
|
|
563
|
-
if (!
|
|
564
|
-
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
568
|
-
if (!company) {
|
|
569
|
-
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
570
|
-
}
|
|
612
|
+
const company = companyByLicenseKey(req, res);
|
|
613
|
+
if (!company) return;
|
|
571
614
|
|
|
572
615
|
const { type } = req.query;
|
|
573
616
|
let sql = `
|
|
@@ -593,14 +636,8 @@ function setupRoutes(app) {
|
|
|
593
636
|
|
|
594
637
|
// Import summary (must come before /:id to avoid matching 'import-summary' as an id)
|
|
595
638
|
app.get('/api/templates/import-summary', (req, res) => {
|
|
596
|
-
const
|
|
597
|
-
if (!
|
|
598
|
-
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
599
|
-
}
|
|
600
|
-
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
601
|
-
if (!company) {
|
|
602
|
-
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
603
|
-
}
|
|
639
|
+
const company = companyByLicenseKey(req, res);
|
|
640
|
+
if (!company) return;
|
|
604
641
|
|
|
605
642
|
// 按设备过滤:导入状态跟着设备(instance_id)走,而非激活码;
|
|
606
643
|
// 未传 instance_id 的旧版客户端保持 company 级聚合(向后兼容)
|
|
@@ -623,15 +660,8 @@ function setupRoutes(app) {
|
|
|
623
660
|
|
|
624
661
|
// Get a single template (full content)
|
|
625
662
|
app.get('/api/templates/:id', (req, res) => {
|
|
626
|
-
const
|
|
627
|
-
if (!
|
|
628
|
-
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
632
|
-
if (!company) {
|
|
633
|
-
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
634
|
-
}
|
|
663
|
+
const company = companyByLicenseKey(req, res);
|
|
664
|
+
if (!company) return;
|
|
635
665
|
|
|
636
666
|
const template = queryGet(
|
|
637
667
|
`SELECT * FROM templates WHERE id = ? AND ${TEMPLATE_VISIBLE}`,
|
|
@@ -981,7 +1011,7 @@ function setupRoutes(app) {
|
|
|
981
1011
|
fs.mkdirSync(LIBTPL_DIR, { recursive: true });
|
|
982
1012
|
fs.renameSync(req.file.path, dest);
|
|
983
1013
|
|
|
984
|
-
const sha256 =
|
|
1014
|
+
const sha256 = await hashFile(dest, 'sha256', 'hex');
|
|
985
1015
|
const name = (req.body?.name || '').trim() || manifest.library?.name || '未命名图谱';
|
|
986
1016
|
queryRun(
|
|
987
1017
|
`INSERT INTO templates (id, company_id, type, name, description, content_json, category, file_path, version, created_at, updated_at)
|
|
@@ -1008,7 +1038,8 @@ function setupRoutes(app) {
|
|
|
1008
1038
|
res.json({ success: true, template: { id, name } });
|
|
1009
1039
|
} catch (err) {
|
|
1010
1040
|
cleanup();
|
|
1011
|
-
|
|
1041
|
+
console.error('[LicenseServer] Library upload failed:', err.message);
|
|
1042
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
1012
1043
|
}
|
|
1013
1044
|
});
|
|
1014
1045
|
|
|
@@ -1087,22 +1118,30 @@ function setupRoutes(app) {
|
|
|
1087
1118
|
}
|
|
1088
1119
|
res.json({ success: true, template: { id, name, assets: assets.length } });
|
|
1089
1120
|
} catch (err) {
|
|
1090
|
-
|
|
1121
|
+
console.error('[LicenseServer] Doc upload failed:', err.message);
|
|
1122
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
1091
1123
|
}
|
|
1092
1124
|
});
|
|
1093
1125
|
|
|
1094
1126
|
// ── Template Import Log (client-facing) ──
|
|
1095
1127
|
app.post('/api/templates/:id/import-log', (req, res) => {
|
|
1096
|
-
const
|
|
1097
|
-
if (!
|
|
1098
|
-
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
1099
|
-
}
|
|
1100
|
-
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
1101
|
-
if (!company) {
|
|
1102
|
-
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
1103
|
-
}
|
|
1128
|
+
const company = companyByLicenseKey(req, res);
|
|
1129
|
+
if (!company) return;
|
|
1104
1130
|
|
|
1105
1131
|
const { instance_id, template_name, template_type } = req.body;
|
|
1132
|
+
// S8(2026-09-23 审查):任意 id 可无限刷导入记录(伪造统计 + 表膨胀),
|
|
1133
|
+
// 只允许记录该公司可见的模板或轻应用包 id
|
|
1134
|
+
const tpl = queryGet(
|
|
1135
|
+
`SELECT id FROM templates WHERE id = ? AND ${TEMPLATE_VISIBLE}`,
|
|
1136
|
+
[req.params.id, company.id, company.id]
|
|
1137
|
+
);
|
|
1138
|
+
const pkg = tpl ? null : queryGet(
|
|
1139
|
+
`SELECT id FROM app_packages WHERE id = ? AND ${PACKAGE_VISIBLE}`,
|
|
1140
|
+
[req.params.id, company.id, company.id]
|
|
1141
|
+
);
|
|
1142
|
+
if (!tpl && !pkg) {
|
|
1143
|
+
return res.status(404).json({ success: false, message: 'Template not found' });
|
|
1144
|
+
}
|
|
1106
1145
|
const id = generateId();
|
|
1107
1146
|
queryRun(
|
|
1108
1147
|
`INSERT INTO template_imports (id, template_id, company_id, instance_id, template_name, template_type)
|
|
@@ -1251,7 +1290,7 @@ function setupRoutes(app) {
|
|
|
1251
1290
|
req.file = null; // 已落位,禁止 cleanup
|
|
1252
1291
|
|
|
1253
1292
|
const stat = fs.statSync(finalPath);
|
|
1254
|
-
const sha256 =
|
|
1293
|
+
const sha256 = await hashFile(finalPath, 'sha256', 'hex');
|
|
1255
1294
|
const finalName = (name || '').trim() || manifest.app?.title || id;
|
|
1256
1295
|
|
|
1257
1296
|
queryRun(
|
|
@@ -1271,7 +1310,7 @@ function setupRoutes(app) {
|
|
|
1271
1310
|
} catch (err) {
|
|
1272
1311
|
cleanup();
|
|
1273
1312
|
console.error('[LicenseServer] Package upload failed:', err.message);
|
|
1274
|
-
res.status(500).json({ success: false, message:
|
|
1313
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
1275
1314
|
}
|
|
1276
1315
|
});
|
|
1277
1316
|
|
|
@@ -1360,7 +1399,7 @@ function setupRoutes(app) {
|
|
|
1360
1399
|
res.json({ success: true, updates });
|
|
1361
1400
|
});
|
|
1362
1401
|
|
|
1363
|
-
app.post('/api/admin/updates', requireAdmin, updateUpload.single('file'), (req, res) => {
|
|
1402
|
+
app.post('/api/admin/updates', requireAdmin, updateUpload.single('file'), async (req, res) => {
|
|
1364
1403
|
const cleanup = () => { if (req.file) fs.unlink(req.file.path, () => {}); };
|
|
1365
1404
|
try {
|
|
1366
1405
|
if (!req.file) return res.status(400).json({ success: false, message: 'file is required' });
|
|
@@ -1373,6 +1412,12 @@ function setupRoutes(app) {
|
|
|
1373
1412
|
cleanup();
|
|
1374
1413
|
return res.status(400).json({ success: false, message: 'version is required' });
|
|
1375
1414
|
}
|
|
1415
|
+
// S14(2026-09-23 审查):version 会插值进 latest.yml,非法值 = YAML 注入面;
|
|
1416
|
+
// 入口限 semver(buildLatestYml 侧还有引号+转义双保险)
|
|
1417
|
+
if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
|
|
1418
|
+
cleanup();
|
|
1419
|
+
return res.status(400).json({ success: false, message: 'version must be semver, e.g. 2.1.8' });
|
|
1420
|
+
}
|
|
1376
1421
|
// 文件名净化:空格等 URL 不友好字符替换(yml 里按此名寻址)
|
|
1377
1422
|
const safeName = (req.file.originalname || `${platform}-${version}`).replace(/[^\w.()-]+/g, '-');
|
|
1378
1423
|
|
|
@@ -1384,7 +1429,7 @@ function setupRoutes(app) {
|
|
|
1384
1429
|
req.file = null;
|
|
1385
1430
|
|
|
1386
1431
|
const stat = fs.statSync(finalPath);
|
|
1387
|
-
const sha512 =
|
|
1432
|
+
const sha512 = await hashFile(finalPath, 'sha512', 'base64');
|
|
1388
1433
|
|
|
1389
1434
|
const isLatest = set_latest !== '0' && set_latest !== 'false';
|
|
1390
1435
|
if (isLatest) {
|
|
@@ -1399,7 +1444,7 @@ function setupRoutes(app) {
|
|
|
1399
1444
|
} catch (err) {
|
|
1400
1445
|
cleanup();
|
|
1401
1446
|
console.error('[LicenseServer] Update upload failed:', err.message);
|
|
1402
|
-
res.status(500).json({ success: false, message:
|
|
1447
|
+
res.status(500).json({ success: false, message: '服务器内部错误' });
|
|
1403
1448
|
}
|
|
1404
1449
|
});
|
|
1405
1450
|
|
|
@@ -1412,13 +1457,10 @@ function setupRoutes(app) {
|
|
|
1412
1457
|
});
|
|
1413
1458
|
|
|
1414
1459
|
// ── Health ──
|
|
1460
|
+
// S12(2026-09-23 审查):/health 无认证,total_companies/active_instances 属运营
|
|
1461
|
+
// 数据不再对外(计数在 /api/admin/instances);compose healthcheck 只看状态码
|
|
1415
1462
|
app.get('/health', (req, res) => {
|
|
1416
|
-
|
|
1417
|
-
SELECT
|
|
1418
|
-
(SELECT COUNT(*) FROM companies) as total_companies,
|
|
1419
|
-
(SELECT COUNT(*) FROM instances WHERE status = 'active' AND last_heartbeat > datetime('now', '-7 days')) as active_instances
|
|
1420
|
-
`);
|
|
1421
|
-
res.json({ status: 'ok', ...stats });
|
|
1463
|
+
res.json({ status: 'ok' });
|
|
1422
1464
|
});
|
|
1423
1465
|
}
|
|
1424
1466
|
|