Files
SimpleNav/admin/actions.php
T
laoyang 4ba512b147 fix: 图标文件名按「整条网址」命名,同域名不同路径不再互相覆盖
https://nas.yangyq.net/calendar 与 https://nas.yangyq.net/ 这类「同域名、
不同路径」的条目,图标文件名原先只由域名决定(md5(域名)),两条记录指向
同一个文件:改其中一个的自定义图标,另一个跟着变;保存时还会 unlink /
覆盖掉对方的文件,直接把对方搞成破图。

- 新增 icon_key($url):按整条网址取键(协议与主机名转小写、空路径补 /、
  查询串与锚点保留),语义是「不同网址 → 不同图标文件」。
- 4 处调用点全部改用它:site_save(上传图标 / 自定义地址)、fetch_meta、
  update_all;键一律取自「记录里存的那条 URL」,不用跳转后的 final_url,
  保证同一个网站每次算出来的键都一样(否则每次重下 + 留下孤儿文件)。
- download_best_icon() / save_custom_icon() 增加空键保护,避免键为空时
  所有图标都落到同一个文件名上。
- README 新增「图标文件的命名规则」一节。
- .gitattributes 统一按 LF 存放,防止 Windows 的 core.autocrlf 在下次
  checkout 时把 PHP/JS/CSS 换成 CRLF。
- e2e 新增「同域名不同路径」回归场景:用三张颜色不同的图,比对两条记录
  落盘图标的内容,验证改 A 不会波及 B;并已做反向验证(把 icon_key 改回
  按域名取键后,这些断言确实失败)。
2026-09-28 17:26:40 +08:00

290 lines
13 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
/**
* 简航 SimpleNav —— 后台操作处理(全部 POST + CSRF)
* 支持 AJAX:请求带 ajax=1 时返回 JSON(用于列表内原地切换显隐/排序)。
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
send_no_cache();
$isAjax = !empty($_POST['ajax']);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . url('admin/'));
exit;
}
csrf_check();
$pdo = db();
$action = (string)($_POST['action'] ?? '');
$back = 'admin/';
if (!empty($_POST['p'])) {
$back = 'admin/?p=' . preg_replace('/[^a-z]/', '', (string)$_POST['p']);
}
function flash(string $m, string $t = 'ok'): void { $_SESSION['flash'] = ['m' => $m, 't' => $t]; }
function post_id(): int { return (int)($_POST['id'] ?? 0); }
/** AJAX 直出 JSON 并结束 */
function json_out(array $data): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
/** 读取某表单状态字段的当前值(用于把最新状态回给前端) */
function state_of(string $table, int $id, string $col): int
{
$st = db()->prepare("SELECT {$col} FROM {$table} WHERE id=?");
$st->execute([$id]);
return (int)$st->fetchColumn();
}
$ajax = null; // 需要在 AJAX 模式下返回的附加数据
try {
switch ($action) {
/* ---------- 网站 ---------- */
case 'site_save': {
$id = post_id();
$gid = (int)($_POST['group_id'] ?? 0);
$url = norm_url((string)($_POST['url'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
$short = trim((string)($_POST['short_name'] ?? ''));
$visible = isset($_POST['is_visible']) ? 1 : 0;
if ($url === '' || $gid <= 0) {
flash('网址和板块不能为空', 'err');
break;
}
// 图标文件的命名键:按整条网址算(同域名不同路径必须互不覆盖,见 icon_key())
$iconKey = icon_key($url);
// ---- 图标 ----
// icon_auto 勾选 = 允许「全部更新」自动刷新图标(icon_custom=0)
// 取消勾选 / 上传文件 / 给地址 = 自定义固定(icon_custom=1)
$iconAuto = isset($_POST['icon_auto']);
$custom = $iconAuto ? 0 : 1;
$iconFile = trim((string)($_POST['icon'] ?? ''));
$uploaded = !empty($_FILES['icon_file']['tmp_name']) && is_uploaded_file((string)$_FILES['icon_file']['tmp_name']);
if ($uploaded) {
$bin = (string)@file_get_contents((string)$_FILES['icon_file']['tmp_name']);
if ($bin === '' || strlen($bin) > 2 * 1024 * 1024) {
flash('图标文件过大或为空(限 2MB)', 'err');
} else {
$new = save_custom_icon($bin, $iconKey);
if ($new !== '') {
$iconFile = $new;
$custom = 1;
} else {
flash('图标文件无法解析,请换一张(PNG / JPG / SVG,建议正方形)', 'err');
}
}
} else {
$iconUrl = trim((string)($_POST['icon_url'] ?? ''));
if ($iconUrl !== '') {
$new = download_best_icon([norm_url($iconUrl)], $iconKey, 1);
if ($new !== '') {
$iconFile = $new;
$custom = 1;
} else {
flash('自定义图标地址下载失败,请检查地址是否可直接访问', 'err');
}
}
}
// 网址换了但没换图标:清掉旧图标,避免张冠李戴
if ($iconFile !== '' && $custom === 0 && !is_file(ICON_PATH . '/' . $iconFile)) {
$iconFile = '';
}
$now = date('Y-m-d H:i:s');
if ($id > 0) {
$st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, icon_custom=?, is_visible=?, updated_at=? WHERE id=?');
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $visible, $now, $id]);
flash($custom ? '已保存(图标已锁定,不再自动更新)' : '已保存网站');
} else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM sites WHERE group_id=' . $gid)->fetchColumn();
$st = $pdo->prepare('INSERT INTO sites (group_id,url,name,short_name,icon,icon_custom,sort_order,is_visible,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
$st->execute([$gid, $url, $name, $short, $iconFile, $custom, $max, $visible, $now, $now]);
flash($custom ? '已添加网站(图标已锁定,不再自动更新)' : '已添加网站');
}
break;
}
case 'site_delete': {
$pdo->prepare('DELETE FROM sites WHERE id=?')->execute([post_id()]);
flash('已删除网站');
break;
}
case 'site_toggle': {
$id = post_id();
$pdo->prepare('UPDATE sites SET is_visible = 1 - is_visible WHERE id=?')->execute([$id]);
$ajax = ['state' => state_of('sites', $id, 'is_visible')];
flash('已切换显示状态');
break;
}
case 'site_move': {
move_sort('sites', post_id(), (string)($_POST['dir'] ?? 'up'), 'group_id');
break;
}
/* ---------- 板块 ---------- */
case 'group_save': {
$id = post_id();
$name = trim((string)($_POST['name'] ?? ''));
$display = ($_POST['display'] ?? 'grid') === 'tabs' ? 'tabs' : 'grid';
$visible = isset($_POST['is_visible']) ? 1 : 0;
if ($name === '') { flash('板块名称不能为空', 'err'); break; }
if ($id > 0) {
$pdo->prepare('UPDATE groups SET name=?, display=?, is_visible=? WHERE id=?')
->execute([$name, $display, $visible, $id]);
flash('已保存板块');
} else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM groups')->fetchColumn();
$pdo->prepare('INSERT INTO groups (name,slug,display,sort_order,is_visible) VALUES (?,?,?,?,?)')
->execute([$name, slugify($name), $display, $max, $visible]);
flash('已添加板块');
}
break;
}
case 'group_delete': {
$pdo->prepare('DELETE FROM groups WHERE id=?')->execute([post_id()]);
flash('已删除板块(含其下网站)');
break;
}
case 'group_toggle': {
$id = post_id();
$pdo->prepare('UPDATE groups SET is_visible = 1 - is_visible WHERE id=?')->execute([$id]);
$ajax = ['state' => state_of('groups', $id, 'is_visible')];
flash('已切换显示状态');
break;
}
case 'group_move': {
move_sort('groups', post_id(), (string)($_POST['dir'] ?? 'up'), null);
break;
}
/* ---------- 搜索引擎 ---------- */
case 'engine_save': {
$id = post_id();
$name = trim((string)($_POST['name'] ?? ''));
$slug = preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['slug'] ?? ''));
$search = trim((string)($_POST['search_url'] ?? ''));
$param = trim((string)($_POST['param'] ?? 'q'));
$sugUrl = trim((string)($_POST['suggest_url'] ?? ''));
$sugType = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['suggest_type'] ?? ''));
$color = preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['color'] ?? '')) ? $_POST['color'] : '#2F6BF7';
$active = isset($_POST['is_active']) ? 1 : 0;
if ($name === '' || $slug === '' || $search === '') { flash('名称/标识/搜索地址不能为空', 'err'); break; }
if ($id > 0) {
$pdo->prepare('UPDATE engines SET name=?,slug=?,search_url=?,param=?,suggest_url=?,suggest_type=?,color=?,is_active=? WHERE id=?')
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $id]);
flash('已保存搜索引擎');
} else {
$max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM engines')->fetchColumn();
$pdo->prepare('INSERT INTO engines (name,slug,search_url,param,suggest_url,suggest_type,color,is_active,sort_order) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $max]);
flash('已添加搜索引擎');
}
break;
}
case 'engine_delete': {
$pdo->prepare('DELETE FROM engines WHERE id=?')->execute([post_id()]);
flash('已删除搜索引擎');
break;
}
case 'engine_toggle': {
$id = post_id();
$pdo->prepare('UPDATE engines SET is_active = 1 - is_active WHERE id=?')->execute([$id]);
$ajax = ['state' => state_of('engines', $id, 'is_active')];
flash('已切换启用状态');
break;
}
case 'engine_move': {
move_sort('engines', post_id(), (string)($_POST['dir'] ?? 'up'), null);
break;
}
/* ---------- 设置 ---------- */
case 'settings_save': {
$srcRaw = (string)($_POST['suggest_source'] ?? 'baidu');
$src = in_array($srcRaw, ['auto', 'baidu', 'bing', 'so360'], true) ? $srcRaw : 'baidu';
$map = [
'site_name' => trim((string)($_POST['site_name'] ?? '')),
'subtitle' => trim((string)($_POST['subtitle'] ?? '')),
'default_engine' => preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['default_engine'] ?? '')),
'primary_color' => preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['primary_color'] ?? '')) ? $_POST['primary_color'] : '#2F6BF7',
'open_blank' => isset($_POST['open_blank']) ? '1' : '0',
'icon_services' => isset($_POST['icon_services']) ? '1' : '0',
'suggest_source' => $src,
];
$st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v');
foreach ($map as $k => $v) { $st->execute([$k, $v]); }
flash('设置已保存');
$back = 'admin/?p=settings';
break;
}
case 'password_save': {
$old = (string)($_POST['old_password'] ?? '');
$new = (string)($_POST['new_password'] ?? '');
$u = current_user();
$st = $pdo->prepare('SELECT password_hash FROM users WHERE id=?');
$st->execute([$u['id']]);
$hash = (string)$st->fetchColumn();
if (!password_verify($old, $hash)) { flash('原密码不正确', 'err'); $back = 'admin/?p=settings'; break; }
if (mb_strlen($new) < 4) { flash('新密码至少 4 位', 'err'); $back = 'admin/?p=settings'; break; }
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([password_hash($new, PASSWORD_DEFAULT), $u['id']]);
flash('密码已更新');
$back = 'admin/?p=settings';
break;
}
default:
flash('未知操作', 'err');
}
} catch (Throwable $e) {
if ($isAjax) {
json_out(['ok' => false, 'msg' => $e->getMessage()]);
}
flash('操作失败:' . $e->getMessage(), 'err');
}
if ($isAjax) {
unset($_SESSION['flash']); // AJAX 不产生页面提示,避免下次进后台弹出过期提示
if ($ajax === null) {
json_out(['ok' => false, 'msg' => '该操作不支持 AJAX']);
}
json_out(['ok' => true] + $ajax);
}
header('Location: ' . url($back));
exit;
/** 上/下移动排序(同组内交换 sort_order) */
function move_sort(string $table, int $id, string $dir, ?string $groupCol): void
{
if ($id <= 0) return;
$pdo = db();
$st = $pdo->prepare("SELECT * FROM {$table} WHERE id=?");
$st->execute([$id]);
$cur = $st->fetch();
if (!$cur) return;
$where = $groupCol ? " AND {$groupCol}=" . (int)$cur[$groupCol] : '';
if ($dir === 'up') {
$sql = "SELECT * FROM {$table} WHERE sort_order < ?{$where} ORDER BY sort_order DESC LIMIT 1";
} else {
$sql = "SELECT * FROM {$table} WHERE sort_order > ?{$where} ORDER BY sort_order ASC LIMIT 1";
}
$st2 = $pdo->prepare($sql);
$st2->execute([$cur['sort_order']]);
$nb = $st2->fetch();
if (!$nb) return;
$upd = $pdo->prepare("UPDATE {$table} SET sort_order=? WHERE id=?");
$upd->execute([$nb['sort_order'], $cur['id']]);
$upd->execute([$cur['sort_order'], $nb['id']]);
}