NewHome/admin/exttools.php

553 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* admin/exttools.php —— 外部工具区管理库首页“外部工具”Tab
* 功能:分区 / 名称 / 网址 / 简介 / 展示范围(科普、红队、蓝队库首页)增删改、排序、启停;
* 链接多时可按 分区/栏目 + 启停状态 + 展示范围 + 关键词 快速筛选定位,并按分区折叠分组编辑;
* 支持按 SecHub/assets/template.json 格式批量导入({section} 分区标记 + {name/url/description} 工具行)。
* 前端库首页按 ext_links.cat_keys 过滤展示:留空 = 所有库通用grp = 分区标题,留空不分组平铺。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
$pdo = db();
$saved = isset($_GET['ok']);
$errorMsg = '';
// ---------- 保存(完整重同步) ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') {
if (!csrf_verify()) {
$errorMsg = '安全校验失败,请刷新页面重试。';
} else {
$raw = (string)($_POST['data'] ?? '');
$rows = json_decode($raw, true);
if (!is_array($rows)) {
$errorMsg = '提交数据格式错误。';
} else {
$clean = [];
foreach ($rows as $i => $r) {
if (!is_array($r)) continue;
$name = trim((string)($r['name'] ?? ''));
$grp = trim(preg_replace('/\s+/', ' ', (string)($r['grp'] ?? '')));
$desc = trim((string)($r['description'] ?? ''));
$url = trim((string)($r['url'] ?? ''));
$cats = [];
if (isset($r['cats']) && is_array($r['cats'])) {
foreach ($r['cats'] as $ck) {
$s = trim((string)$ck);
if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) {
$cats[] = $s;
}
}
}
if ($name === '' && $url === '') continue; // 空行忽略
if ($name === '' || $url === '') {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与网址均不能为空。';
break;
}
if (!preg_match('#^https?://#i', $url)) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。';
break;
}
$clean[] = [
'id' => (int)($r['id'] ?? 0),
'name' => $name,
'grp' => $grp,
'description' => $desc,
'url' => $url,
'cats' => implode(',', $cats),
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
];
}
if ($errorMsg === '') {
try {
$pdo->beginTransaction();
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->fetchAll(), 'id'));
$keep = [];
foreach ($clean as $c) { if ($c['id'] > 0) $keep[] = $c['id']; }
$del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) {
$pdo->prepare('DELETE FROM ext_links WHERE id = ?')->execute([$did]);
}
$stUpd = $pdo->prepare('UPDATE ext_links SET name=?, url=?, description=?, grp=?, cat_keys=?, enabled=?, sort=? WHERE id=?');
$stIns = $pdo->prepare('INSERT INTO ext_links (name, url, description, grp, cat_keys, enabled, sort) VALUES (?,?,?,?,?,?,?)');
$sort = 0;
foreach ($clean as $c) {
$sort++;
if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) {
$stUpd->execute([$c['name'], $c['url'], $c['description'], $c['grp'], $c['cats'], $c['enabled'], $sort, $c['id']]);
} else {
$stIns->execute([$c['name'], $c['url'], $c['description'], $c['grp'], $c['cats'], $c['enabled'], $sort]);
}
}
$pdo->commit();
touch_last_updated();
header('Location: exttools.php?ok=1');
exit;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$errorMsg = '保存失败:' . $e->getMessage();
}
}
}
}
}
// ---------- 行内即时启停(单条生效,无需整表保存) ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggle') {
header('Content-Type: application/json; charset=utf-8');
if (!csrf_verify()) {
echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE);
} else {
$tid = (int)($_POST['id'] ?? 0);
$ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
if ($tid > 0) {
$pdo->prepare('UPDATE ext_links SET enabled = ? WHERE id = ?')->execute([$ten, $tid]);
touch_last_updated();
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'msg' => '无效的记录 ID。'], JSON_UNESCAPED_UNICODE);
}
}
exit;
}
// ---------- 展示(按 sort 排序) ----------
$tools = $pdo->query('SELECT * FROM ext_links ORDER BY sort ASC, id ASC')->fetchAll();
// 供“分区”筛选下拉使用(按出现顺序去重)
$grpNames = [];
foreach ($tools as $t) {
$g = trim((string)($t['grp'] ?? ''));
if ($g !== '' && !in_array($g, $grpNames, true)) {
$grpNames[] = $g;
}
}
layout_head('外部工具区管理');
admin_topbar('exttools');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">外部工具区管理</h2>
<p class="tip">
链接较多时可先筛选再编辑:按 分区/栏目、启停状态、展示范围、关键词 快速过滤(仅影响当前浏览,不影响保存)。
行首“分区 / 栏目”即前台分组标题(如“合集 / 导航”),<strong>留空 = 不分组平铺</strong>
展示范围勾选库,<strong>全部不勾选 = 所有库通用</strong>。
</p>
<?php if ($saved): ?>
<div class="ok-msg">保存成功,库首页外部工具区已同步更新。</div>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<div class="err-msg"><?= he($errorMsg) ?></div>
<?php endif; ?>
<!-- 筛选工具条 -->
<div class="fieldset-card">
<div class="ext-filter">
<select id="fGrp" title="按分区/栏目筛选">
<option value="">分区:全部</option>
<option value="__none__">未分组</option>
<?php foreach ($grpNames as $gn): ?>
<option value="<?= he($gn) ?>"><?= he($gn) ?></option>
<?php endforeach; ?>
</select>
<select id="fState" title="按启停状态筛选">
<option value="">状态:全部</option>
<option value="1">启用</option>
<option value="0">停用</option>
</select>
<select id="fLib" title="按展示范围筛选(通用=所有库可见)">
<option value="">展示范围:不限</option>
<option value="__gen__">仅通用(所有库)</option>
<option value="popular">含科普库</option>
<option value="red">含红队库</option>
<option value="blue">含蓝队库</option>
</select>
<input type="text" id="fKw" placeholder="关键词:匹配名称 / 简介 / 网址 / 分区…">
<button type="button" class="btn btn-sm" id="fClear">清空筛选</button>
<button type="button" class="btn btn-sm" id="foldAll" title="折叠/展开所有分区">收起全部</button>
<span class="ext-stat" id="extStat"></span>
</div>
</div>
<!-- 批量导入(模板格式与 SecHub/assets/template.json 一致) -->
<div class="fieldset-card">
<div class="fs-title">批量导入外部工具(快速导入,自动带分区)</div>
<textarea id="extImportTa" rows="8" spellcheck="false" style="font-family:Consolas,Menlo,monospace;font-size:13px" placeholder='[&#10; { "section": "合集" },&#10; { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" },&#10; { "name": "工具B", "url": "https://example.com/b" },&#10; { "section": "导航" },&#10; { "name": "站点C", "url": "https://example.com/c" }&#10;]'></textarea>
<p class="tip" style="margin-top:6px">
数组内可穿插分区标记 <code>{"section":"栏目名"}</code>(其后的工具归入该分区,直至下一分区标记);
工具行为 <code>{"name","url","description"}</code>,名称与网址缺失的空行会自动忽略。
点击「解析并追加」后将<strong>追加到对应分区下方</strong>,核对后统一点「保存外部工具」生效。
</p>
<div style="margin-top:8px"><button type="button" class="btn btn-sm btn-primary" id="extImportBtn">解析并追加到列表</button></div>
</div>
<!-- 分区折叠编辑列表 -->
<div class="fieldset-card">
<form method="post" id="extForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<textarea name="data" id="extData" hidden></textarea>
<div id="extRows">
<?php
$extGroups = [];
foreach ($tools as $t) {
$g = trim((string)($t['grp'] ?? ''));
$extGroups[$g === '' ? "\x00" : $g][] = $t;
}
foreach ($extGroups as $gk => $gItems):
$isNone = ($gk === "\x00");
$gLabel = $isNone ? '未分组' : $gk;
$gData = $isNone ? '__none__' : $gk;
?>
<div class="xg" data-g="<?= he($gData) ?>">
<div class="xg-h" title="点击折叠 / 展开">
<button type="button" class="xg-arr" aria-hidden="true">▾</button>
<span class="xg-name"><?= he($gLabel) ?></span>
<span class="xg-cnt"><?= count($gItems) ?> 条</span>
<?php if ($isNone): ?>
<span class="xg-hint tip">分区留空的工具在此平铺</span>
<?php endif; ?>
</div>
<div class="xg-b">
<?php foreach ($gItems as $t):
$cats = array_map('trim', explode(',', trim((string)$t['cat_keys']))); ?>
<div class="t-row" data-id="<?= (int)$t['id'] ?>">
<input type="text" class="t-grp" value="<?= he((string)($t['grp'] ?? '')) ?>" placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组平铺">
<input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="工具名称(必填)">
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话简介">
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="完整网址,如 https://example.com">
<span class="t-cats" title="展示范围(不勾选=所有库通用)">
<span class="t-cats-t">展示于</span>
<label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $cats, true) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $cats, true) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $cats, true) ? ' checked' : '' ?>>蓝队</label>
</span>
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
<span class="ops">
<button type="button" class="btn btn-sm t-up" title="同分区内上移">↑</button>
<button type="button" class="btn btn-sm t-down" title="同分区内下移">↓</button>
<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:12px 0">
<button type="button" class="btn btn-sm" id="extAdd">+ 添加工具(未分组)</button>
<button type="submit" class="btn btn-primary">保存外部工具</button>
<span class="tip">筛选只是便于浏览,全部行都会随「保存」提交</span>
</div>
</form>
</div>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var rows = document.getElementById('extRows');
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function rowHTML(grp, name, desc, url) {
grp = grp || ''; name = name || ''; desc = desc || ''; url = url || '';
return '<div class="t-row" data-id="0">' +
'<input type="text" class="t-grp" value="' + esc(grp) + '" placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组直接平铺">' +
'<input type="text" class="t-name" value="' + esc(name) + '" placeholder="工具名称(必填)">' +
'<input type="text" class="t-desc" value="' + esc(desc) + '" placeholder="一句话简介">' +
'<input type="text" class="t-url" value="' + esc(url) + '" placeholder="完整网址,如 https://example.com">' +
'<span class="t-cats" title="展示范围(不勾选=所有库通用)">' +
'<span class="t-cats-t">展示于</span>' +
'<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' +
'<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' +
'<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' +
'</span>' +
'<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm t-up" title="同分区内上移">↑</button>' +
'<button type="button" class="btn btn-sm t-down" title="同分区内下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' +
'</span></div>';
}
function allGroups() {
var out = [];
Array.prototype.forEach.call(rows.children, function (c) {
if (c.classList.contains('xg')) out.push(c);
});
return out;
}
function findGroup(key) {
var found = null;
Array.prototype.forEach.call(rows.children, function (c) {
if (!found && c.classList.contains('xg') && c.getAttribute('data-g') === key) found = c;
});
return found;
}
function ensureGroup(key) {
var cur = findGroup(key);
if (cur) return cur;
var label = key === '__none__' ? '未分组' : key;
var xg = document.createElement('div');
xg.className = 'xg';
xg.setAttribute('data-g', key);
xg.innerHTML = '<div class="xg-h" title="点击折叠 / 展开">' +
'<button type="button" class="xg-arr" aria-hidden="true">▾</button>' +
'<span class="xg-name">' + esc(label) + '</span>' +
'<span class="xg-cnt"></span>' +
(key === '__none__' ? '<span class="xg-hint tip">分区留空的工具在此平铺</span>' : '') +
'</div><div class="xg-b"></div>';
rows.appendChild(xg);
return xg;
}
function appendRowTo(grpName, html) {
var key = grpName && grpName.trim() ? grpName.trim() : '__none__';
var xg = ensureGroup(key);
var tmp = document.createElement('div');
tmp.innerHTML = html;
xg.querySelector('.xg-b').appendChild(tmp.firstChild);
}
function pruneEmptyGroups() {
var xgs = allGroups();
for (var i = xgs.length - 1; i >= 0; i--) {
if (!xgs[i].querySelector('.t-row')) xgs[i].remove();
}
}
function refreshStats() {
var total = rows.querySelectorAll('.t-row').length;
Array.prototype.forEach.call(allGroups(), function (xg) {
var cnt = xg.querySelectorAll('.t-row').length;
var c = xg.querySelector('.xg-cnt');
if (c) c.textContent = cnt + ' 条';
});
applyFilter();
var stat = document.getElementById('extStat');
if (stat) stat.textContent = '共 ' + total + ' 条';
}
// ---------- 筛选 ----------
var fGrp = document.getElementById('fGrp');
var fState = document.getElementById('fState');
var fLib = document.getElementById('fLib');
var fKw = document.getElementById('fKw');
function rowVisible(r) {
var gv = (r.querySelector('.t-grp').value || '').trim();
if (fGrp.value) {
if (fGrp.value === '__none__') { if (gv !== '') return false; }
else if (gv !== fGrp.value) return false;
}
if (fState.value !== '') {
var on = r.querySelector('.t-enabled').checked ? 1 : 0;
if (String(on) !== fState.value) return false;
}
if (fLib.value) {
var any = false, hit = false;
var cs = r.querySelectorAll('.t-catc');
for (var i = 0; i < cs.length; i++) {
if (cs[i].checked) {
any = true;
if (cs[i].getAttribute('data-k') === fLib.value) hit = true;
}
}
if (fLib.value === '__gen__') { if (any) return false; }
else if (any && !hit) return false;
}
if (fKw.value) {
var hay = ((r.querySelector('.t-name').value || '') + ' ' +
(r.querySelector('.t-desc').value || '') + ' ' +
(r.querySelector('.t-url').value || '') + ' ' + gv).toLowerCase();
if (hay.indexOf(fKw.value.toLowerCase()) === -1) return false;
}
return true;
}
function applyFilter() {
var shown = 0, total = 0;
var all = rows.querySelectorAll('.t-row');
Array.prototype.forEach.call(all, function (r) {
var ok = rowVisible(r);
r.classList.toggle('is-hide', !ok);
if (ok) shown++;
total++;
});
Array.prototype.forEach.call(allGroups(), function (xg) {
var vis = false;
var rs = xg.querySelectorAll('.t-row');
for (var i = 0; i < rs.length; i++) {
if (!rs[i].classList.contains('is-hide')) { vis = true; break; }
}
xg.classList.toggle('is-hide', !vis);
});
var stat = document.getElementById('extStat');
if (stat) stat.textContent = total ? ('显示 ' + shown + ' / 共 ' + total + ' 条') : '共 0 条';
}
var filterEls = [fGrp, fState, fLib];
for (var fi = 0; fi < filterEls.length; fi++) {
if (filterEls[fi]) filterEls[fi].addEventListener('change', applyFilter);
}
if (fKw) fKw.addEventListener('input', applyFilter);
if (document.getElementById('fClear')) {
document.getElementById('fClear').addEventListener('click', function () {
fGrp.value = ''; fState.value = ''; fLib.value = ''; fKw.value = '';
applyFilter();
});
}
if (document.getElementById('foldAll')) {
document.getElementById('foldAll').addEventListener('click', function () {
var xgs = allGroups();
var anyOpen = false;
for (var i = 0; i < xgs.length; i++) {
if (!xgs[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
for (var j = 0; j < xgs.length; j++) {
xgs[j].classList.toggle('is-fold', anyOpen);
}
var btn = document.getElementById('foldAll');
btn.textContent = anyOpen ? '展开全部' : '收起全部';
});
}
// ---------- 组头折叠 / 行操作 ----------
rows.addEventListener('click', function (ev) {
var xgh = ev.target.closest('.xg-h');
if (xgh) {
xgh.parentNode.classList.toggle('is-fold');
return;
}
var row = ev.target.closest('.t-row');
if (!row) return;
if (ev.target.classList.contains('t-del')) {
if (confirm('确认删除该外部工具?')) {
row.remove();
pruneEmptyGroups();
refreshStats();
}
} else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) {
var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
if (sib && sib.classList.contains('t-row')) {
if (ev.target.classList.contains('t-up')) row.parentNode.insertBefore(row, sib);
else row.parentNode.insertBefore(sib, row);
}
}
});
// 分区/启停/展示范围 等变更
rows.addEventListener('change', function (ev) {
var t = ev.target;
if (t.classList.contains('t-enabled')) {
var input = t;
var row = input.closest('.t-row');
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) return;
if (!id) {
input.checked = !input.checked;
alert('该行尚未保存过,请先点击「保存外部工具」生成记录,之后即可直接拨动开关启停。');
return;
}
var fd = new FormData();
fd.append('action', 'toggle');
fd.append('csrf', csrfEl.value);
fd.append('id', String(id));
fd.append('enabled', input.checked ? '1' : '0');
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (!j || j.ok !== true) {
input.checked = !input.checked;
alert('启停失败:' + ((j && j.msg) || '未知错误'));
}
})
.catch(function () {
input.checked = !input.checked;
alert('网络错误,启停未保存。');
});
return;
}
if (t.classList.contains('t-grp')) {
var row2 = t.closest('.t-row');
var key = t.value.trim() === '' ? '__none__' : t.value.trim();
var xg = ensureGroup(key);
xg.querySelector('.xg-b').appendChild(row2);
pruneEmptyGroups();
refreshStats();
return;
}
if (t.classList.contains('t-catc')) {
applyFilter();
}
});
// ---------- 新增 / 批量导入 ----------
document.getElementById('extAdd').addEventListener('click', function () {
appendRowTo('', rowHTML('', '', '', ''));
refreshStats();
});
var importBtn = document.getElementById('extImportBtn');
if (importBtn) {
importBtn.addEventListener('click', function () {
var ta = document.getElementById('extImportTa');
var txt = (ta ? ta.value : '').trim();
if (!txt) { alert('请先在上方粘贴 JSON 数据(可参考 SecHub/assets/template.json。'); return; }
var arr;
try { arr = JSON.parse(txt); } catch (e) { alert('JSON 解析失败:' + e.message); return; }
if (!Array.isArray(arr)) { alert('JSON 顶层需为数组(与 SecHub/assets/template.json 相同)。'); return; }
var cur = '', added = 0, bad = 0;
Array.prototype.forEach.call(arr, function (it) {
if (!it || typeof it !== 'object') return;
if (it.section !== undefined) { cur = String(it.section).trim(); return; }
var nm = String(it.name || '').trim();
var ur = String(it.url || '').trim();
var de = String(it.description || '').trim();
if (!nm && !ur) return;
if (!nm || !ur) { bad++; return; }
appendRowTo(cur, rowHTML(cur, nm, de, ur));
added++;
});
refreshStats();
var msg = '解析完成:新增 ' + added + ' 行,已归入对应分区。';
if (bad > 0) msg += '另有 ' + bad + ' 行因缺少名称或网址被跳过。';
msg += '\n请核对分区与内容后点击「保存外部工具」生效。';
alert(msg);
});
}
// ---------- 保存:收集与前端校验 ----------
document.getElementById('extForm').addEventListener('submit', function (ev) {
var list = [], bad = false, badMsg = '';
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
if (bad) return;
var grp = r.querySelector('.t-grp').value.trim();
var name = r.querySelector('.t-name').value.trim();
var desc = r.querySelector('.t-desc').value.trim();
var url = r.querySelector('.t-url').value.trim();
var enabled = r.querySelector('.t-enabled').checked ? '1' : '0';
var cats = [];
var catInputs = r.querySelectorAll('.t-catc');
for (var cc = 0; cc < catInputs.length; cc++) {
if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k'));
}
if (!name && !url) return;
if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与网址均不能为空。'; return; }
if (!/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:需填写 http(s):// 开头的完整网址。'; return; }
list.push({ id: parseInt(r.getAttribute('data-id'), 10) || 0, grp: grp, name: name, description: desc, url: url, enabled: enabled, cats: cats });
});
if (bad) { ev.preventDefault(); alert(badMsg); return; }
document.getElementById('extData').value = JSON.stringify(list);
});
// 初始状态
refreshStats();
})();
</script>
</body>
</html>