feat: 简航 SimpleNav 个人网站导航系统(PHP + SQLite)

- 前台:搜索引擎切换+联想下拉、网格板块(常用/工具)、合集标签页
- 后台:初始化/登录、网站/板块/引擎增删改、上下移排序
- 添加网站自动抓取名称与图标(存储于服务器),支持自定义简称
- 一键全部更新(批量刷新图标+名称,保留自定义简称)
- 搜索联想服务端代理(必应/百度/谷歌/搜狗/360/DuckDuckGo)
- 零依赖:原生 CSS/JS,PDO SQLite
This commit is contained in:
2026-09-28 15:48:35 +08:00
parent 714874f11f
commit 6e83cc3ecc
44 changed files with 2169 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
# 运行时数据(数据库与抓取的图标不纳入版本库)
/data/*
!/data/.htaccess
!/data/index.html
/uploads/icons/*
!/uploads/icons/.gitkeep
# 杂项
*.db
*.log
.DS_Store
Thumbs.db
+8
View File
@@ -0,0 +1,8 @@
# SimpleNav —— 根目录安全规则
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^data/ - [F,L]
RewriteRule ^\.git/ - [F,L]
</IfModule>
# 关闭目录浏览
Options -Indexes
+67 -2
View File
@@ -1,3 +1,68 @@
# SimpleNav # SimpleNav · 简航
简航,一个面向个人的轻量级导航系统 简航(SimpleNav),一个面向个人的**轻量级网站导航系统**。PHP + SQLite,零外部依赖、无需 Composer,上传即用。
## 特点
- **轻量**:纯 PHP + SQLite,不依赖 MySQL / 框架 / Composer;前端无 Bootstrap / jQuery,原生 CSS + JS。
- **个人向**:单管理员,页面就是「你自己常用网站的起始页」。
- **搜索引擎可切换 + 联想下拉**:后台勾选喜欢的搜索引擎即可显示在顶部;输入时下拉联想词由服务端代理(规避跨域)。
- **添加即补全**:填网址自动抓取网站名称、图标(图标下载并规范化后存到服务器本地)。
- **显示简称**:每个网站有「名称(自动)」和「简称(手动)」两个字段,页面展示你自定义的简称。
- **自定义排序**:网站 / 板块 / 搜索引擎都可上移下移排序。
- **一键全部更新**:批量刷新所有网站的图标与名称,**不会改动你自定义的简称**。
- **合集标签页**:把「合集」做成标签页,点标签平铺该合集下的全部链接。
## 页面结构(复刻 n.yangyq.net 风格)
1. 顶部:搜索引擎切换 + 搜索框(带联想下拉)
2. 中部:网格板块(如「常用」「工具」)
3. 下部:「合集」标签页
## 目录结构
```
SimpleNav/
├─ index.php 前台首页
├─ suggest.php 搜索联想代理(多引擎,服务端请求)
├─ login.php / logout.php 管理员登录 / 首次初始化
├─ db.php 数据库初始化(建表 + 默认数据)
├─ config.php 基础配置
├─ functions.php 公共函数(抓取元信息、下载图标等)
├─ admin/ 后台(网站 / 板块 / 搜索引擎 / 设置)
│ ├─ index.php 后台页面
│ ├─ actions.php 增删改 / 排序处理
│ ├─ fetch_meta.php 添加网站时自动抓取名称与图标
│ └─ update_all.php 一键“全部更新”(分批)
├─ assets/ CSS / JS / 图标
├─ uploads/icons/ 抓取到的网站图标(需可写)
└─ data/ SQLite 数据库(需可写,禁止 Web 访问)
```
## 部署
1. 上传全部文件到站点目录(如 `/www/wwwroot/n.yangyq.net/simplenav`)。
2. 确保 web 用户对 `data/` 与 `uploads/icons/` 有写权限。
3. 浏览器打开站点 → 首次访问进入「初始化管理员」,创建账号后即可在后台管理。
4. 建议在 nginx server{} 中加入 `deploy/nginx-simplenav-deny-data.conf` 里的规则(禁止访问 `data/`)。
服务器上创建最小权限部署账号:以 root 运行 `deploy/setup-simplenav-user.sh`。
## 搜索引擎联想接口说明
联想接口需服务端代理(浏览器直接调用会跨域)。内置解析类型:
| 类型 | 平台 | 联想接口 |
|---|---|---|
| `bing_osjson` | 必应 | `https://api.bing.com/osjson.aspx?query={q}` |
| `baidu_sugrec` | 百度 | `https://www.baidu.com/sugrec?prod=pc&wd={q}` |
| `google_firefox` | 谷歌 | `https://suggestqueries.google.com/complete/search?client=firefox&q={q}` |
| `sogou_ajaj` | 搜狗 | `https://www.sogou.com/suggnew/ajajjson?type=web&key={q}` |
| `so360` | 360 | `https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}` |
| `ddg` | DuckDuckGo | `https://duckduckgo.com/ac/?type=list&q={q}` |
> 注:国内服务器访问 Google / DuckDuckGo 可能不通,联想会优雅降级为空(不影响搜索跳转)。
## 许可
Apache License 2.0
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* 简航 SimpleNav —— 后台认证与通用工具
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/functions.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
function admin_count(): int
{
return (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
function current_user(): ?array
{
if (empty($_SESSION['uid'])) {
return null;
}
$st = db()->prepare('SELECT id, username FROM users WHERE id=?');
$st->execute([$_SESSION['uid']]);
$u = $st->fetch();
return $u ?: null;
}
function require_login(): void
{
if (!current_user()) {
header('Location: ' . url('login.php'));
exit;
}
}
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf" value="' . e(csrf_token()) . '">';
}
function csrf_check(): void
{
$t = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!hash_equals(csrf_token(), $t)) {
http_response_code(419);
exit('CSRF 校验失败,请刷新页面重试。');
}
}
/** 统一的后台页面外壳 */
function admin_layout(string $active, string $title, string $body): void
{
$nav = [
'sites' => ['网站管理', 'admin/'],
'groups' => ['板块管理', 'admin/?p=groups'],
'engines' => ['搜索引擎', 'admin/?p=engines'],
'settings' => ['系统设置', 'admin/?p=settings'],
];
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(site_name()) ?> 后台</title>
<link rel="stylesheet" href="<?= e(url('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(url('assets/css/admin.css')) ?>">
</head>
<body class="admin">
<header class="topbar">
<div class="brand"><a href="<?= e(url()) ?>"><?= e(site_name()) ?></a> <span>后台</span></div>
<nav class="topnav">
<?php foreach ($nav as $k => $v): ?>
<a href="<?= e(url($v[1])) ?>" class="<?= $k === $active ? 'active' : '' ?>"><?= e($v[0]) ?></a>
<?php endforeach; ?>
</nav>
<div class="topright">
<span class="who"><?= e(current_user()['username'] ?? '') ?></span>
<a class="btn ghost" href="<?= e(url('logout.php')) ?>">退出</a>
</div>
</header>
<main class="wrap"><?= $body ?></main>
<script src="<?= e(url('assets/js/admin.js')) ?>"></script>
</body>
</html><?php
}
+205
View File
@@ -0,0 +1,205 @@
<?php
/**
* 简航 SimpleNav —— 后台操作处理(全部 POST + CSRF)
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
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); }
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'] ?? ''));
$icon = trim((string)($_POST['icon'] ?? ''));
$visible = isset($_POST['is_visible']) ? 1 : 0;
if ($url === '' || $gid <= 0) {
flash('网址和板块不能为空', 'err');
break;
}
$now = date('Y-m-d H:i:s');
if ($id > 0) {
$st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, is_visible=?, updated_at=? WHERE id=?');
$st->execute([$gid, $url, $name, $short, $icon, $visible, $now, $id]);
flash('已保存网站');
} 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,sort_order,is_visible,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)');
$st->execute([$gid, $url, $name, $short, $icon, $max, $visible, $now, $now]);
flash('已添加网站');
}
break;
}
case 'site_delete': {
$pdo->prepare('DELETE FROM sites WHERE id=?')->execute([post_id()]);
flash('已删除网站');
break;
}
case 'site_toggle': {
$pdo->prepare('UPDATE sites SET is_visible = 1 - is_visible WHERE id=?')->execute([post_id()]);
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': {
$pdo->prepare('UPDATE groups SET is_visible = 1 - is_visible WHERE id=?')->execute([post_id()]);
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': {
$pdo->prepare('UPDATE engines SET is_active = 1 - is_active WHERE id=?')->execute([post_id()]);
flash('已切换启用状态');
break;
}
case 'engine_move': {
move_sort('engines', post_id(), (string)($_POST['dir'] ?? 'up'), null);
break;
}
/* ---------- 设置 ---------- */
case 'settings_save': {
$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',
];
$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) {
flash('操作失败:' . $e->getMessage(), 'err');
}
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']]);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* 简航 SimpleNav —— 添加/编辑网站时自动抓取元信息(供后台 AJAX 调用)
* 返回:{ ok, name, short, icon, url }
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
$url = isset($_GET['url']) ? norm_url((string)$_GET['url']) : '';
if ($url === '') {
echo json_encode(['ok' => false, 'msg' => '缺少网址'], JSON_UNESCAPED_UNICODE);
exit;
}
$meta = fetch_site_meta($url);
$domain = domain_of($meta['final_url'] ?: $url);
$iconFile = '';
if (!empty($meta['icon'])) {
$iconFile = download_icon($meta['icon'], $domain);
}
if ($iconFile === '' && $domain !== '') {
// 兜底再试一次根目录 favicon.ico
$iconFile = download_icon('http://' . $domain . '/favicon.ico', $domain);
}
echo json_encode([
'ok' => true,
'url' => $url,
'name' => $meta['name'] !== '' ? $meta['name'] : ($meta['title'] !== '' ? $meta['title'] : $domain),
'short' => $meta['name'] !== '' ? $meta['name'] : $domain,
'icon' => $iconFile,
'icon_url' => $iconFile !== '' ? icon_src($iconFile) : '',
], JSON_UNESCAPED_UNICODE);
+258
View File
@@ -0,0 +1,258 @@
<?php
/**
* 简航 SimpleNav —— 后台首页(网站 / 板块 / 搜索引擎 / 设置)
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
$pdo = db();
$p = isset($_GET['p']) ? (string)$_GET['p'] : 'sites';
if (!in_array($p, ['sites', 'groups', 'engines', 'settings'], true)) {
$p = 'sites';
}
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
$groups = $pdo->query('SELECT * FROM groups ORDER BY sort_order, id')->fetchAll();
$engines = $pdo->query('SELECT * FROM engines ORDER BY sort_order, id')->fetchAll();
$SUGGEST_TYPES = ['bing_osjson', 'baidu_sugrec', 'google_firefox', 'sogou_ajaj', 'so360', 'ddg'];
function flash_html(?array $f): string
{
if (!$f) return '';
$cls = ($f['t'] ?? 'ok') === 'err' ? 'alert err' : 'alert ok';
return '<div class="' . $cls . '">' . e($f['m']) . '</div>';
}
function toggle_form(string $action, int $id): string
{
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline">'
. csrf_field() . '<input type="hidden" name="action" value="' . e($action) . '">'
. '<input type="hidden" name="id" value="' . $id . '">'
. '<button class="ico-btn" title="切换显示">' . "\u{1F441}" . '</button></form>';
}
function move_form(string $action, int $id, string $dir, string $label, string $p): string
{
return '<form method="post" action="' . e(url('admin/actions.php')) . '" class="inline">'
. csrf_field() . '<input type="hidden" name="action" value="' . e($action) . '">'
. '<input type="hidden" name="id" value="' . $id . '">'
. '<input type="hidden" name="dir" value="' . $dir . '">'
. '<input type="hidden" name="p" value="' . e($p) . '">'
. '<button class="ico-btn" title="' . e($label) . '">' . ($dir === 'up' ? '↑' : '↓') . '</button></form>';
}
ob_start();
if ($p === 'sites'):
$rows = $pdo->query('SELECT s.*, g.name AS gname FROM sites s JOIN groups g ON g.id=s.group_id ORDER BY g.sort_order, g.id, s.sort_order, s.id')->fetchAll();
?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head">
<h2>新增 / 编辑网站</h2>
<button type="button" class="btn ghost" id="btn-update-all" data-url="<?= e(url('admin/update_all.php')) ?>">⟳ 全部更新(图标+名称)</button>
</div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="site-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="site_save">
<input type="hidden" name="id" id="f-id" value="0">
<input type="hidden" name="icon" id="f-icon" value="">
<label>板块
<select name="group_id" id="f-group" required>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>"><?= e($g['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="span2">网址
<span class="row">
<input type="text" name="url" id="f-url" placeholder="https://example.com" required>
<button type="button" class="btn ghost" id="btn-fetch" data-url="<?= e(url('admin/fetch_meta.php')) ?>">自动获取</button>
</span>
</label>
<label>网站名称(自动)
<input type="text" name="name" id="f-name" placeholder="自动抓取">
</label>
<label>简称(页面显示,手动)
<input type="text" name="short_name" id="f-short" placeholder="如:知乎">
</label>
<label class="chk"><input type="checkbox" name="is_visible" id="f-visible" checked> 显示</label>
<div class="span2 form-actions">
<button type="submit" class="btn primary">保存</button>
<button type="button" class="btn ghost" id="btn-reset">清空</button>
<span class="hint" id="fetch-hint"></span>
</div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>网站列表(<?= count($rows) ?>)</h2></div>
<table class="tbl">
<thead><tr><th>图标</th><th>简称(展示)</th><th>名称</th><th>网址</th><th>板块</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($rows as $r): $label = $r['short_name'] !== '' ? $r['short_name'] : ($r['name'] !== '' ? $r['name'] : domain_of($r['url'])); $src = icon_src($r['icon']); ?>
<tr>
<td><?php if ($src): ?><img class="mini" src="<?= e($src) ?>" alt=""><?php else: ?><span class="mini letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span><?php endif; ?></td>
<td><?= e($label) ?></td>
<td class="muted"><?= e($r['name']) ?></td>
<td class="muted ell"><a href="<?= e($r['url']) ?>" target="_blank" rel="noopener"><?= e($r['url']) ?></a></td>
<td><?= e($r['gname']) ?></td>
<td><?= toggle_form('site_toggle', (int)$r['id']) ?></td>
<td class="nowrap"><?= move_form('site_move', (int)$r['id'], 'up', '上移', '') . move_form('site_move', (int)$r['id'], 'down', '下移', '') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-site"
data-id="<?= (int)$r['id'] ?>" data-group="<?= (int)$r['group_id'] ?>"
data-url="<?= e($r['url']) ?>" data-name="<?= e($r['name']) ?>"
data-short="<?= e($r['short_name']) ?>" data-icon="<?= e($r['icon']) ?>"
data-visible="<?= (int)$r['is_visible'] ?>" title="编辑">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该网站?');">
<?= csrf_field() ?><input type="hidden" name="action" value="site_delete">
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button class="ico-btn danger" title="删除">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<?php
elseif ($p === 'groups'): ?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head"><h2>新增 / 编辑板块</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="group-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="group_save">
<input type="hidden" name="id" id="g-id" value="0">
<input type="hidden" name="p" value="groups">
<label>板块名称<input type="text" name="name" id="g-name" required></label>
<label>展示方式
<select name="display" id="g-display">
<option value="grid">普通网格板块(如:常用 / 工具)</option>
<option value="tabs">合集标签页</option>
</select>
</label>
<label class="chk"><input type="checkbox" name="is_visible" id="g-visible" checked> 显示</label>
<div class="span2 form-actions"><button class="btn primary">保存</button><button type="button" class="btn ghost" id="g-reset">清空</button></div>
</form>
</section>
<section class="card">
<table class="tbl">
<thead><tr><th>名称</th><th>展示方式</th><th>网站数</th><th>显示</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($groups as $g): $cnt = (int)$pdo->query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?>
<tr>
<td><?= e($g['name']) ?></td>
<td><?= $g['display'] === 'tabs' ? '合集标签页' : '普通网格' ?></td>
<td><?= $cnt ?></td>
<td><?= toggle_form('group_toggle', (int)$g['id']) ?></td>
<td class="nowrap"><?= move_form('group_move', (int)$g['id'], 'up', '上移', 'groups') . move_form('group_move', (int)$g['id'], 'down', '下移', 'groups') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-group" data-id="<?= (int)$g['id'] ?>" data-name="<?= e($g['name']) ?>" data-display="<?= e($g['display']) ?>" data-visible="<?= (int)$g['is_visible'] ?>">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('删除板块会同时删除其下所有网站,确定?');">
<?= csrf_field() ?><input type="hidden" name="action" value="group_delete"><input type="hidden" name="p" value="groups"><input type="hidden" name="id" value="<?= (int)$g['id'] ?>">
<button class="ico-btn danger">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<?php
elseif ($p === 'engines'): ?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head"><h2>新增 / 编辑搜索引擎</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form" id="engine-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="engine_save">
<input type="hidden" name="id" id="e-id" value="0">
<input type="hidden" name="p" value="engines">
<label>名称<input type="text" name="name" id="e-name" required></label>
<label>标识 slug<input type="text" name="slug" id="e-slug" placeholder="bing" required></label>
<label class="span2">搜索地址<input type="text" name="search_url" id="e-search" placeholder="https://cn.bing.com/search" required></label>
<label>参数名<input type="text" name="param" id="e-param" placeholder="q / wd / query"></label>
<label>主题色<input type="text" name="color" id="e-color" placeholder="#60A504"></label>
<label class="span2">联想接口({q} 为关键词占位)<input type="text" name="suggest_url" id="e-sug" placeholder="https://api.bing.com/osjson.aspx?query={q}"></label>
<label>联想解析类型
<select name="suggest_type" id="e-sugtype">
<?php foreach ($SUGGEST_TYPES as $t): ?><option value="<?= e($t) ?>"><?= e($t) ?></option><?php endforeach; ?>
</select>
</label>
<label class="chk"><input type="checkbox" name="is_active" id="e-active"> 启用(显示在首页)</label>
<div class="span2 form-actions"><button class="btn primary">保存</button><button type="button" class="btn ghost" id="e-reset">清空</button></div>
</form>
</section>
<section class="card">
<table class="tbl">
<thead><tr><th>名称</th><th>搜索地址</th><th>联想类型</th><th>启用</th><th>排序</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($engines as $en): ?>
<tr>
<td><span style="color:<?= e($en['color']) ?>">■</span> <?= e($en['name']) ?> <span class="muted">(<?= e($en['slug']) ?>)</span></td>
<td class="muted ell"><?= e($en['search_url']) ?></td>
<td class="muted"><?= e($en['suggest_type']) ?></td>
<td><?= $en['is_active'] ? '<span class="ok-dot">已启用</span>' : '<span class="muted">未启用</span>' ?> <?= toggle_form('engine_toggle', (int)$en['id']) ?></td>
<td class="nowrap"><?= move_form('engine_move', (int)$en['id'], 'up', '上移', 'engines') . move_form('engine_move', (int)$en['id'], 'down', '下移', 'engines') ?></td>
<td class="nowrap">
<button type="button" class="ico-btn edit-engine"
data-id="<?= (int)$en['id'] ?>" data-name="<?= e($en['name']) ?>" data-slug="<?= e($en['slug']) ?>"
data-search="<?= e($en['search_url']) ?>" data-param="<?= e($en['param']) ?>" data-sug="<?= e($en['suggest_url']) ?>"
data-sugtype="<?= e($en['suggest_type']) ?>" data-color="<?= e($en['color']) ?>" data-active="<?= (int)$en['is_active'] ?>">✎</button>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="inline" onsubmit="return confirm('确定删除该引擎?');">
<?= csrf_field() ?><input type="hidden" name="action" value="engine_delete"><input type="hidden" name="p" value="engines"><input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
<button class="ico-btn danger">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="hint">提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。</p>
</section>
<?php
else: // settings ?>
<?= flash_html($flash) ?>
<section class="card">
<div class="card-head"><h2>站点设置</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="settings_save">
<label>站点名称<input type="text" name="site_name" value="<?= e(setting('site_name', '简航')) ?>"></label>
<label>副标题<input type="text" name="subtitle" value="<?= e(setting('subtitle', '个人网站导航')) ?>"></label>
<label>默认搜索引擎
<select name="default_engine">
<?php foreach ($engines as $en): ?>
<option value="<?= e($en['slug']) ?>" <?= setting('default_engine') === $en['slug'] ? 'selected' : '' ?>><?= e($en['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>主题色<input type="text" name="primary_color" value="<?= e(setting('primary_color', '#2F6BF7')) ?>"></label>
<label class="chk"><input type="checkbox" name="open_blank" <?= setting('open_blank', '1') === '1' ? 'checked' : '' ?>> 搜索结果新窗口打开</label>
<div class="span2 form-actions"><button class="btn primary">保存设置</button></div>
</form>
</section>
<section class="card">
<div class="card-head"><h2>修改管理员密码</h2></div>
<form method="post" action="<?= e(url('admin/actions.php')) ?>" class="grid-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="password_save">
<label>原密码<input type="password" name="old_password" autocomplete="current-password" required></label>
<label>新密码<input type="password" name="new_password" autocomplete="new-password" required></label>
<div class="span2 form-actions"><button class="btn primary">修改密码</button></div>
</form>
</section>
<?php
endif;
$body = ob_get_clean();
admin_layout($p, '后台', $body);
+61
View File
@@ -0,0 +1,61 @@
<?php
/**
* 简航 SimpleNav —— 一键“全部更新”(分批)。
* 批量刷新每个网站的图标与名称;**保留用户自定义的简称 short_name 不变**。
* 前端按 offset 分批调用,避免一次请求超时。
* 返回:{ done, total, offset, updated, failed }
*/
declare(strict_types=1);
require_once __DIR__ . '/_auth.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
set_time_limit(30);
$offset = max(0, (int)($_GET['offset'] ?? 0));
$limit = min(12, max(1, (int)($_GET['limit'] ?? 6)));
$pdo = db();
$total = (int)$pdo->query('SELECT COUNT(*) FROM sites')->fetchColumn();
$rows = $pdo->query('SELECT id, url FROM sites ORDER BY id LIMIT ' . $limit . ' OFFSET ' . $offset)->fetchAll();
$updated = 0;
$failed = 0;
$now = date('Y-m-d H:i:s');
foreach ($rows as $row) {
$url = norm_url((string)$row['url']);
$meta = fetch_site_meta($url);
$domain = domain_of($meta['final_url'] ?: $url);
$name = $meta['name'] !== '' ? $meta['name'] : $meta['title'];
$iconFile = '';
if (!empty($meta['icon'])) {
$iconFile = download_icon($meta['icon'], $domain);
}
if ($iconFile === '' && $domain !== '') {
$iconFile = download_icon('http://' . $domain . '/favicon.ico', $domain);
}
$sets = ['updated_at=?'];
$args = [$now];
if ($name !== '') { $sets[] = 'name=?'; $args[] = $name; }
if ($iconFile !== '') { $sets[] = 'icon=?'; $args[] = $iconFile; }
// 注意:这里绝不修改 short_name(用户自定义简称)
$args[] = (int)$row['id'];
try {
$pdo->prepare('UPDATE sites SET ' . implode(',', $sets) . ' WHERE id=?')->execute($args);
if ($iconFile !== '' || $name !== '') { $updated++; } else { $failed++; }
} catch (Throwable $e) {
$failed++;
}
}
$next = $offset + count($rows);
echo json_encode([
'done' => $next >= $total || count($rows) === 0,
'total' => $total,
'offset' => $next,
'updated' => $updated,
'failed' => $failed,
], JSON_UNESCAPED_UNICODE);
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+74
View File
@@ -0,0 +1,74 @@
/* 简航 SimpleNav —— 后台样式 */
body.admin { background: #eef0f3; color: #333; font-size: 14px; }
.topbar {
display: flex; align-items: center; gap: 16px; padding: 0 20px; height: 56px;
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); position: sticky; top: 0; z-index: 50;
}
.topbar .brand { font-size: 17px; font-weight: 700; }
.topbar .brand a { color: #2F6BF7; }
.topbar .brand span { color: #999; font-weight: 400; font-size: 13px; margin-left: 6px; }
.topnav { display: flex; gap: 4px; flex: 1; }
.topnav a { padding: 8px 14px; border-radius: 8px; color: #555; }
.topnav a:hover { background: #f2f4f7; }
.topnav a.active { background: #2F6BF7; color: #fff; }
.topright { display: flex; align-items: center; gap: 10px; color: #666; }
.wrap { max-width: 1140px; margin: 20px auto 60px; padding: 0 16px; }
.card { background: #fff; border-radius: 12px; padding: 18px; margin-bottom: 18px; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.card-head h2 { font-size: 16px; margin: 0; }
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
.grid-form label { display: flex; flex-direction: column; font-size: 13px; color: #555; gap: 5px; }
.grid-form label.chk { flex-direction: row; align-items: center; gap: 6px; }
.grid-form .span2 { grid-column: 1 / -1; }
.grid-form .row { display: flex; gap: 8px; }
.grid-form .row input { flex: 1; }
.grid-form input[type=text], .grid-form input[type=password], .grid-form select {
height: 38px; padding: 0 10px; border: 1px solid #d9dde3; border-radius: 8px; font-size: 14px; width: 100%;
}
.grid-form input:focus, .grid-form select:focus { outline: none; border-color: #2F6BF7; }
.form-actions { display: flex; align-items: center; gap: 10px; }
.hint { color: #999; font-size: 12px; }
.btn { display: inline-flex; align-items: center; gap: 6px; height: 38px; padding: 0 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
.btn.primary { background: #2F6BF7; color: #fff; }
.btn.primary:hover { background: #2456cc; }
.btn.ghost { background: #f2f4f7; color: #444; }
.btn.ghost:hover { background: #e6e9ee; }
.btn.block { width: 100%; justify-content: center; }
.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; }
.alert.ok { background: #e8f6ee; color: #1a7f45; }
.alert.err { background: #fdecec; color: #c0392b; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th, .tbl td { padding: 9px 8px; text-align: left; border-bottom: 1px solid #f0f1f3; vertical-align: middle; }
.tbl th { color: #888; font-weight: 500; }
.tbl tr:hover td { background: #fafbfc; }
.tbl .muted { color: #999; }
.tbl .ell { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tbl .nowrap { white-space: nowrap; }
.mini { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; background: #f4f5f7; }
.mini.letter { display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; }
.inline { display: inline; }
.ico-btn { border: none; background: #f2f4f7; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; color: #555; margin: 0 1px; }
.ico-btn:hover { background: #e6e9ee; }
.ico-btn.danger { color: #c0392b; }
.ok-dot { color: #1a7f45; font-size: 12px; }
/* 登录页 */
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card { width: 340px; background: #fff; border-radius: 14px; padding: 30px 26px; box-shadow: 0 10px 30px rgba(0,0,0,.08); text-align: center; }
.login-card h1 { font-size: 20px; margin: 8px 0 2px; }
.login-card .sub { color: #999; font-size: 13px; margin: 0 0 18px; }
.login-card input { width: 100%; height: 42px; margin-bottom: 12px; padding: 0 12px; border: 1px solid #d9dde3; border-radius: 8px; font-size: 14px; }
.login-card input:focus { outline: none; border-color: #2F6BF7; }
.login-card .btn { height: 42px; }
.login-logo { width: 56px; height: 56px; }
@media (max-width: 720px) {
.grid-form { grid-template-columns: 1fr; }
.topbar { flex-wrap: wrap; height: auto; padding: 10px 14px; }
.tbl .ell { max-width: 120px; }
}
+116
View File
@@ -0,0 +1,116 @@
/* 简航 SimpleNav —— 样式(复刻 n.yangyq.net 的灰底白卡片风格,去 Bootstrap 依赖) */
:root { --primary: #2F6BF7; }
* { box-sizing: border-box; }
body {
margin: 0;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: #d8d9da;
color: #333;
overflow-x: hidden;
}
a { text-decoration: none; color: inherit; }
.container { max-width: 1140px; margin: 50px auto 40px; padding: 0 15px; }
/* ---------- 搜索 ---------- */
.s-search { position: relative; z-index: 2000; margin-bottom: 10px; }
.search-type { margin-bottom: 0; }
.s-engine {
display: inline-block; padding: 6px 14px; margin: 0 2px 0 0;
background: rgba(255,255,255,.45); border-radius: 5px 5px 0 0; cursor: pointer;
font-size: 14px; line-height: 20px; transition: background .2s;
}
.s-engine span { color: var(--ec, #555); }
.s-engine:hover { background: rgba(255,255,255,.75); }
.s-engine.active { background: #fff; }
#search-form { position: relative; }
#search-text {
width: 100%; height: 50px; line-height: 50px; text-indent: 14px; font-size: 16px;
border: none; border-radius: 5px; background: #fff; outline: 0;
box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3); transition: box-shadow .3s;
}
#search-text:focus { box-shadow: 0 0 24px 0 rgba(50,50,50,.08); }
#search button.submit {
position: absolute; top: 7px; right: 7px; width: 60px; height: 36px;
background: none; border: 0; border-radius: 3px; cursor: pointer; outline: none;
}
#search button.submit::after {
content: ""; display: block; width: 16px; height: 16px; margin: 0 auto;
border: 2px solid #c9c9c9; border-radius: 50%; position: relative;
}
#search button.submit::before {
content: ""; position: absolute; right: 20px; bottom: 9px; width: 7px; height: 2px;
background: #c9c9c9; transform: rotate(45deg);
}
#word {
position: absolute; list-style: none; top: 55px; left: 0; width: 100%; margin: 0;
background: #fff; border-radius: 5px; z-index: 20000; padding: 10px;
box-shadow: 0 0 10px #bbb; display: none;
}
#word li { height: 35px; padding: 0 8px; line-height: 35px; cursor: pointer; font-size: 16px; border-radius: 5px; }
#word li:hover { background: #eee; }
/* ---------- 板块卡片 ---------- */
.mylist {
list-style: none; margin: 16px 0; padding: 12px 20px;
background: #fff; border-radius: 15px; box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3);
display: flex; flex-wrap: wrap;
}
.mylist > li.title {
width: 100%; margin: 0 0 10px; padding: 0 0 8px 10px; height: auto;
font-size: 16px; font-weight: 600; line-height: 24px;
border-bottom: 1px solid #eee; position: relative;
}
.mylist > li.title::before {
content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px;
background: var(--primary); border-radius: 2px;
}
.mylist > li.site { margin: 2px; }
.mylist li.site a {
display: flex; flex-direction: column; align-items: center; width: 100px; padding: 10px 4px;
border-radius: 10px; transition: background .2s;
}
.mylist li.site a:hover { background: #f0f1f2; }
.mylist li.site .icon { width: 45px; height: 45px; border-radius: 10px; background: #fff; object-fit: contain; }
.mylist li.site .ico-letter {
width: 45px; height: 45px; border-radius: 10px; color: #fff; font-size: 20px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
}
.mylist li.site .name {
width: 100%; margin-top: 6px; text-align: center; font-size: 13px; color: #555;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ---------- 合集标签页 ---------- */
.collections { margin: 16px 0; }
.collections-title {
font-size: 16px; font-weight: 600; padding: 0 0 8px 10px; margin: 0 0 10px;
position: relative;
}
.collections-title::before {
content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px;
background: var(--primary); border-radius: 2px;
}
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.tabs .tab {
border: none; background: rgba(255,255,255,.65); color: #444; cursor: pointer;
padding: 7px 16px; border-radius: 18px; font-size: 14px; transition: all .2s;
}
.tabs .tab:hover { background: #fff; }
.tabs .tab.active { background: var(--primary); color: #fff; }
.tab-panel { display: none; }
.tab-panel.active { display: flex; }
/* ---------- 页脚 ---------- */
.foot { text-align: center; color: #8a8a8a; font-size: 12px; margin: 24px 0 0; }
.foot a { color: #8a8a8a; }
.foot a:hover { color: var(--primary); }
/* ---------- 响应式 ---------- */
@media (max-width: 767px) {
.container { margin-top: 24px; }
.mylist { border-radius: 10px; padding: 10px 12px; }
.mylist li.site a { width: 25%; }
.mylist li.site .icon, .mylist li.site .ico-letter { width: 40px; height: 40px; }
.mylist li.site .name { font-size: 12px; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="简航 SimpleNav">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3B77FF"/>
<stop offset="1" stop-color="#2F6BF7"/>
</linearGradient>
</defs>
<circle cx="256" cy="256" r="256" fill="url(#bg)"/>
<rect x="116" y="116" width="280" height="280" rx="72" fill="none" stroke="#FFFFFF" stroke-width="40" transform="rotate(45 256 256)"/>
<g fill="#FFFFFF">
<circle cx="256" cy="232" r="31"/>
<path d="M192 324 C192 288 220 270 256 270 C292 270 320 288 320 324 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 636 B

+106
View File
@@ -0,0 +1,106 @@
/* 简航 SimpleNav —— 后台交互 */
(function () {
'use strict';
var $ = function (s) { return document.querySelector(s); };
/* ---------- 自动获取网站信息 ---------- */
var fetchBtn = $('#btn-fetch');
if (fetchBtn) {
fetchBtn.addEventListener('click', function () {
var url = ($('#f-url') || {}).value || '';
var hint = $('#fetch-hint');
if (!url) { if (hint) hint.textContent = '请先填写网址'; return; }
if (hint) hint.textContent = '正在获取…';
fetchBtn.disabled = true;
fetch(fetchBtn.getAttribute('data-url') + '?url=' + encodeURIComponent(url))
.then(function (r) { return r.json(); })
.then(function (d) {
fetchBtn.disabled = false;
if (!d.ok) { if (hint) hint.textContent = d.msg || '获取失败'; return; }
if ($('#f-name')) $('#f-name').value = d.name || '';
if ($('#f-short') && !$('#f-short').value) $('#f-short').value = d.short || d.name || '';
if ($('#f-icon')) $('#f-icon').value = d.icon || '';
if (hint) hint.textContent = (d.icon ? '已获取名称与图标' : '已获取名称(未找到图标)') + (d.url ? ':' + d.url : '');
})
.catch(function () { fetchBtn.disabled = false; if (hint) hint.textContent = '网络错误'; });
});
}
var resetBtn = $('#btn-reset');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
['f-id', 'f-url', 'f-name', 'f-short', 'f-icon'].forEach(function (id) { var el = $('#' + id); if (el) el.value = id === 'f-id' ? '0' : ''; });
var v = $('#f-visible'); if (v) v.checked = true;
var h = $('#fetch-hint'); if (h) h.textContent = '';
});
}
/* ---------- 编辑网站:回填表单 ---------- */
document.querySelectorAll('.edit-site').forEach(function (btn) {
btn.addEventListener('click', function () {
var d = btn.dataset;
$('#f-id').value = d.id;
$('#f-group').value = d.group;
$('#f-url').value = d.url;
$('#f-name').value = d.name;
$('#f-short').value = d.short;
$('#f-icon').value = d.icon;
$('#f-visible').checked = d.visible === '1';
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
/* ---------- 编辑板块 / 引擎 ---------- */
document.querySelectorAll('.edit-group').forEach(function (btn) {
btn.addEventListener('click', function () {
var d = btn.dataset;
$('#g-id').value = d.id; $('#g-name').value = d.name;
$('#g-display').value = d.display; $('#g-visible').checked = d.visible === '1';
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
var gReset = $('#g-reset');
if (gReset) gReset.addEventListener('click', function () { $('#g-id').value = '0'; $('#g-name').value = ''; $('#g-display').value = 'grid'; $('#g-visible').checked = true; });
document.querySelectorAll('.edit-engine').forEach(function (btn) {
btn.addEventListener('click', function () {
var d = btn.dataset;
$('#e-id').value = d.id; $('#e-name').value = d.name; $('#e-slug').value = d.slug;
$('#e-search').value = d.search; $('#e-param').value = d.param; $('#e-sug').value = d.sug;
$('#e-sugtype').value = d.sugtype; $('#e-color').value = d.color; $('#e-active').checked = d.active === '1';
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
var eReset = $('#e-reset');
if (eReset) eReset.addEventListener('click', function () {
['e-id', 'e-name', 'e-slug', 'e-search', 'e-sug', 'e-color'].forEach(function (id) { var el = $('#' + id); if (el) el.value = id === 'e-id' ? '0' : ''; });
$('#e-param').value = 'q'; $('#e-active').checked = false;
});
/* ---------- 一键“全部更新”(分批循环) ---------- */
var upBtn = $('#btn-update-all');
if (upBtn) {
upBtn.addEventListener('click', function () {
if (!confirm('将批量刷新所有网站的图标与名称(用户自定义简称不受影响),确定继续?')) return;
var base = upBtn.getAttribute('data-url');
var total = 0, updated = 0, failed = 0, offset = 0;
upBtn.disabled = true;
function step() {
fetch(base + '?offset=' + offset + '&limit=6')
.then(function (r) { return r.json(); })
.then(function (d) {
total = d.total; updated += d.updated; failed += d.failed; offset = d.offset;
upBtn.textContent = '⟳ 更新中 ' + Math.min(offset, total) + '/' + total;
if (!d.done) { step(); }
else {
upBtn.disabled = false;
upBtn.textContent = '⟳ 全部更新(图标+名称)';
alert('更新完成:成功 ' + updated + ',失败/无变化 ' + failed + ',共 ' + total + ' 个网站。');
location.reload();
}
})
.catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(图标+名称)'; alert('更新中断,请重试。'); });
}
step();
});
}
})();
+90
View File
@@ -0,0 +1,90 @@
/* 简航 SimpleNav —— 前台交互:搜索引擎切换 + 联想下拉 + 合集标签页 */
(function () {
'use strict';
var cfg = window.SIMPLENAV || {};
/* ---------- 搜索引擎切换 ---------- */
var tabs = document.querySelectorAll('#engine-tabs .s-engine');
var form = document.getElementById('search-form');
var input = document.getElementById('search-text');
function activateEngine(el) {
tabs.forEach(function (t) { t.classList.remove('active'); });
el.classList.add('active');
var search = el.getAttribute('data-search');
var param = el.getAttribute('data-param');
var slug = el.getAttribute('data-slug');
var ph = el.getAttribute('data-placeholder') || '搜索';
if (form) { form.setAttribute('action', search); }
if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); }
form.setAttribute('data-slug', slug);
hideWord();
}
tabs.forEach(function (el) {
el.addEventListener('click', function () { activateEngine(el); input && input.focus(); });
});
/* ---------- 联想下拉 ---------- */
var word = document.getElementById('word');
var timer = null;
var lastQ = '';
function hideWord() { if (word) { word.innerHTML = ''; word.style.display = 'none'; } }
function showWord(items) {
if (!word) return;
if (!items || !items.length) { hideWord(); return; }
word.innerHTML = items.map(function (t) {
var d = document.createElement('div');
d.textContent = t;
return '<li>' + d.innerHTML + '</li>';
}).join('');
word.style.display = 'block';
}
if (input) {
input.addEventListener('input', function () {
var q = input.value.trim();
if (q === '') { hideWord(); return; }
if (q === lastQ) return;
lastQ = q;
clearTimeout(timer);
timer = setTimeout(function () {
var slug = form ? (form.getAttribute('data-slug') || '') : '';
fetch(cfg.suggest + '?slug=' + encodeURIComponent(slug) + '&q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) { showWord(data && data.items); })
.catch(function () { hideWord(); });
}, 180);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && word && word.style.display === 'block' && word.firstChild) {
// 默认走第一条建议
input.value = word.firstChild.textContent;
hideWord();
}
});
}
document.addEventListener('click', function (e) {
if (word && !word.contains(e.target) && e.target !== input) { hideWord(); }
var li = e.target.closest ? e.target.closest('#word li') : null;
if (li) {
input.value = li.textContent;
hideWord();
if (form) { form.submit(); }
}
});
/* ---------- 合集标签页 ---------- */
var collTabs = document.querySelectorAll('#coll-tabs .tab');
collTabs.forEach(function (tab) {
tab.addEventListener('click', function () {
collTabs.forEach(function (t) { t.classList.remove('active'); });
tab.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); });
var panel = document.getElementById(tab.getAttribute('data-target'));
if (panel) { panel.classList.add('active'); }
});
});
})();
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 简航 SimpleNav —— 基础配置
* 面向个人的轻量级网站导航系统
*/
declare(strict_types=1);
date_default_timezone_set('Asia/Shanghai');
if (function_exists('mb_internal_encoding')) {
mb_internal_encoding('UTF-8');
}
define('APP_NAME', '简航');
define('APP_PATH', __DIR__);
define('DATA_PATH', APP_PATH . '/data'); // 数据库等敏感数据(禁止 Web 访问)
define('DB_PATH', DATA_PATH . '/nav.db');
define('ICON_PATH', APP_PATH . '/uploads/icons'); // 抓取的网站图标(可公开访问)
define('ICON_URL', 'uploads/icons'); // 相对站点根
/**
* 自动探测应用根 URL,兼容「放在子目录 /simplenav」与「放在根目录」两种部署。
* 返回形如 ''(根目录)或 '/simplenav'。
*/
function app_base(): string
{
$dir = isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '/';
$dir = str_replace('\\', '/', dirname($dir));
$dir = preg_replace('#/admin$#', '', $dir); // admin/ 下的脚本回退到应用根
$dir = rtrim($dir, '/');
return $dir === '/' ? '' : $dir;
}
/** 生成带应用根的 URL */
function url(string $path = ''): string
{
return app_base() . '/' . ltrim($path, '/');
}
/** 站点名(可在后台设置里覆盖) */
function site_name(): string
{
return setting('site_name', APP_NAME);
}
/** 读取设置项 */
function setting(string $key, string $default = ''): string
{
static $cache = null;
if ($cache === null) {
$cache = [];
try {
foreach (db()->query('SELECT k, v FROM settings') as $row) {
$cache[$row['k']] = $row['v'];
}
} catch (Throwable $e) {
$cache = [];
}
}
return array_key_exists($key, $cache) && $cache[$key] !== '' ? $cache[$key] : $default;
}
+8
View File
@@ -0,0 +1,8 @@
# 禁止 Web 访问 data 目录(SQLite 数据库等敏感文件)
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+1
View File
@@ -0,0 +1 @@
<!-- 占位,防止目录被列出 -->
+226
View File
@@ -0,0 +1,226 @@
<?php
/**
* 简航 SimpleNav —— 数据库(PDO SQLite)
* 首次访问自动建表并写入默认数据(含从 n.yangyq.net 迁移的常用/工具/合集链接)。
*/
declare(strict_types=1);
require_once __DIR__ . '/config.php';
function db(): PDO
{
static $pdo = null;
if ($pdo === null) {
if (!is_dir(DATA_PATH)) {
@mkdir(DATA_PATH, 0755, true);
}
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
db_init($pdo);
}
return $pdo;
}
function db_init(PDO $pdo): void
{
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT DEFAULT '',
display TEXT NOT NULL DEFAULT 'grid', -- grid=普通网格板块 tabs=合集标签页
sort_order INTEGER NOT NULL DEFAULT 0,
is_visible INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
url TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '', -- 自动抓取的网站名称
short_name TEXT NOT NULL DEFAULT '', -- 用户自定义简称(页面展示用)
icon TEXT NOT NULL DEFAULT '', -- 本地图标文件名
sort_order INTEGER NOT NULL DEFAULT 0,
is_visible INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT '',
updated_at TEXT DEFAULT '',
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS engines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL,
search_url TEXT NOT NULL,
param TEXT NOT NULL DEFAULT 'q',
suggest_url TEXT NOT NULL DEFAULT '',
suggest_type TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#2F6BF7',
is_active INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT ''
);
SQL);
// 是否为空库(用于判断是否写入默认数据)
$isEmpty = (int)$pdo->query('SELECT COUNT(*) FROM groups')->fetchColumn() === 0;
seed_settings($pdo);
seed_engines($pdo);
if ($isEmpty) {
seed_groups_and_sites($pdo);
}
}
function seed_settings(PDO $pdo): void
{
$defaults = [
'site_name' => '简航',
'subtitle' => '个人网站导航',
'default_engine' => 'bing',
'primary_color' => '#2F6BF7',
'open_blank' => '1',
];
$stmt = $pdo->prepare('INSERT OR IGNORE INTO settings (k, v) VALUES (?, ?)');
foreach ($defaults as $k => $v) {
$stmt->execute([$k, $v]);
}
}
function seed_engines(PDO $pdo): void
{
if ((int)$pdo->query('SELECT COUNT(*) FROM engines')->fetchColumn() > 0) {
return;
}
// name, slug, search_url, param, suggest_url, suggest_type, color, active, sort
$rows = [
['必应', 'bing', 'https://cn.bing.com/search', 'q', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1, 1],
['百度', 'baidu', 'https://www.baidu.com/s', 'wd', 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1, 2],
['谷歌', 'google', 'https://www.google.com/search', 'q', 'https://suggestqueries.google.com/complete/search?client=firefox&q={q}', 'google_firefox','#3B83FA', 0, 3],
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', 'https://www.sogou.com/suggnew/ajajjson?type=web&key={q}', 'sogou_ajaj', '#FE6B14', 0, 4],
['360', 'so360', 'https://www.so.com/s', 'q', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}', 'so360', '#1BB04A', 0, 5],
['DuckGo','ddg', 'https://duckduckgo.com/', 'q', 'https://duckduckgo.com/ac/?type=list&q={q}', 'ddg', '#DE5833', 0, 6],
];
$stmt = $pdo->prepare('INSERT INTO engines (name,slug,search_url,param,suggest_url,suggest_type,color,is_active,sort_order) VALUES (?,?,?,?,?,?,?,?,?)');
foreach ($rows as $r) {
$stmt->execute($r);
}
}
function seed_groups_and_sites(PDO $pdo): void
{
// 板块定义: [名称, 类型, 排序, [ [名称, 网址], ... ] ]
$data = [
['常用', 'grid', 1, [
['海大', 'http://www.ouc.edu.cn/'],
['阿里云', 'https://www.aliyun.com/'],
['知乎', 'https://www.zhihu.com/'],
['天翼云盘', 'https://cloud.189.cn/'],
['快科技', 'http://www.mydrivers.com/'],
['轻舟', 'https://yangjh.net/'],
['老杨', 'https://yangyq.net/'],
['红围脖', 'https://hongbowei.com/'],
]],
['工具', 'grid', 2, [
['宝塔', 'https://bt.yangyq.net:8868/44d5b346'],
['海大VPN', 'https://otrust.ouc.edu.cn'],
['BB系统', 'https://wlkc.ouc.edu.cn/'],
['教务系统', 'http://jwgl.ouc.edu.cn/'],
['信息门户', 'http://my.ouc.edu.cn/'],
['信息学部', 'http://it.ouc.edu.cn/'],
['淘宝', 'https://www.taobao.com/'],
['京东', 'https://www.jd.com/'],
['日历', 'https://nas.yangyq.net/calendar'],
['办公Nas', 'https://nas.yangyq.net/'],
['家庭Nas', 'https://home.yangyq.net/'],
['Git', 'https://git.yangyq.net/'],
['网盘', 'https://nas.yangyq.net/drive/'],
['电影', 'https://movie.yangyq.net/'],
['相册', 'https://home.yangyq.net/photo'],
['文件', 'https://nas.yangyq.net/file'],
['bilibili', 'https://www.bilibili.com/'],
['优酷', 'https://www.youku.com/'],
['爱奇艺', 'https://www.iqiyi.com/'],
['腾讯视频', 'https://v.qq.com/'],
['芒果TV', 'https://www.mgtv.com/'],
['Draw.IO', 'https://draw.yangyq.net'],
['稿定PS', 'https://ps.gaoding.com/'],
['密码生成', 'https://n.yangyq.net/password/'],
['PDF工具', 'https://pdf.wps.cn/'],
['微信读书', 'https://weread.qq.com/'],
['高德地图', 'https://gaode.com/'],
['百度地图', 'https://map.baidu.com/'],
['建设银行', 'http://ccb.com/cn/home/index.html'],
['海大财务', 'http://hdcw.ouc.edu.cn'],
['MOOC', 'https://www.icourse163.org/'],
]],
['常用软件', 'tabs', 10, [
['WPS', 'https://www.wps.cn/'],
['Chrome', 'https://www.google.cn/chrome/index.html'],
['VS Code', 'https://code.visualstudio.com/'],
['Draw.IO', 'https://github.com/jgraph/drawio-desktop/releases'],
['火绒', 'https://www.huorong.cn/'],
['7-Zip', 'https://sparanoid.com/lab/7z/'],
['Tabby', 'https://github.com/Eugeny/tabby/'],
['GitAhead', 'https://gitahead.github.io/gitahead.com/'],
['Everything', 'https://www.voidtools.com/zh-cn/'],
['Wox', 'https://github.com/Wox-launcher/Wox'],
['BCUninstaller', 'https://github.com/Klocman/Bulk-Crap-Uninstaller'],
['DBeaver', 'https://github.com/dbeaver/dbeaver'],
['Thunderbird', 'https://www.thunderbird.net/zh-CN/'],
['FDM', 'https://www.freedownloadmanager.org/zh/'],
['GIMP', 'https://www.gimp.org/'],
['VLC', 'https://www.videolan.org/vlc/'],
['Zotero', 'https://www.zotero.org/'],
['QQ音乐', 'https://y.qq.com/'],
['微信', 'https://weixin.qq.com/'],
['TIM', 'https://tim.qq.com/'],
['Shotcut', 'https://github.com/mltframework/shotcut'],
]],
['图片资源', 'tabs', 11, [
['图标库', 'https://www.iconfont.cn/'],
['插画库', 'https://undraw.co/illustrations'],
['Pixabay', 'https://pixabay.com/zh/'],
['Pexels', 'https://www.pexels.com/'],
]],
['科研论文', 'tabs', 12, [
['DeepL翻译', 'https://www.deepl.com/'],
['Linggle', 'https://linggle.com/'],
['Netspeak', 'https://netspeak.org/'],
['Semantic Scholar', 'https://www.semanticscholar.org/'],
['LaTex Table', 'https://www.tablesgenerator.com/'],
]],
];
$g = $pdo->prepare('INSERT INTO groups (name, slug, display, sort_order, is_visible) VALUES (?,?,?,?,1)');
$s = $pdo->prepare('INSERT INTO sites (group_id, url, name, short_name, icon, sort_order, is_visible, created_at, updated_at) VALUES (?,?,?,?,\'\',?,1,?,?)');
$now = date('Y-m-d H:i:s');
foreach ($data as $row) {
[$name, $display, $order, $sites] = $row;
$g->execute([$name, slugify($name), $display, $order]);
$gid = (int)$pdo->lastInsertId();
$i = 0;
foreach ($sites as $site) {
$s->execute([$gid, $site[1], $site[0], $site[0], $i++, $now, $now]);
}
}
}
/** 生成 slug(中文退化为拼音不可用时用 md5 短串) */
function slugify(string $s): string
{
$s = trim($s);
if (preg_match('/^[a-z0-9\-]+$/i', $s)) {
return strtolower($s);
}
return 'g' . substr(md5($s), 0, 6);
}
+14
View File
@@ -0,0 +1,14 @@
# SimpleNav 安全规则:禁止通过 Web 访问数据库等敏感目录
# 用法:把下面几行加入宝塔站点 n.yangyq.net 的 nginx server{} 块中,然后 reload nginx。
# (放在 /simplenav 子目录部署时用第一条;以后放根目录时用第二组。)
# —— 子目录部署(当前测试阶段)——
location ~ ^/simplenav/data/ { deny all; return 404; }
location ~ ^/simplenav/\.git/ { deny all; return 404; }
# —— 以后放到根目录时启用 ——
# location ~ ^/data/ { deny all; return 404; }
# location ~ ^/\.git/ { deny all; return 404; }
# 可选:禁止 uploads 目录执行脚本(防上传木马)
location ~* ^/simplenav/uploads/.*\.(php|phtml|phar)$ { deny all; return 404; }
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
#
# SimpleNav(简航)部署账号初始化脚本
# 在服务器上以 root 身份运行一次即可(可重复运行,幂等)
#
# 作用:
# 1. 创建 simplenav 部署用户(无密码,只能密钥登录,无任何特权组)
# 2. 安装本地 ssh 公钥
# 3. 只给 /www/wwwroot/n.yangyq.net 的读写权限(POSIX ACL,不改原有属主)
# 4. 预建应用目录 /simplenav/{data,uploads/icons} 并让部署用户与 web 用户都可读写
#
set -euo pipefail
# ==================== 配置 ====================
DEPLOY_USER="simplenav"
SITE="/www/wwwroot/n.yangyq.net"
APP_SUBDIR="simplenav" # 先部署到子目录测试,通过后再放根目录
PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOCvCGMMkyf9CHaedSdZe4XfXrS0MCW15REibldEF6U4 deploy@n.yangyq.net"
# ==============================================
[ "$(id -u)" -eq 0 ] || { echo "错误:请用 root 运行"; exit 1; }
[ -d "$SITE" ] || { echo "错误:目录不存在 $SITE"; exit 1; }
command -v setfacl >/dev/null 2>&1 || {
echo "错误:缺少 setfacl,请先安装 acl"
echo " CentOS/RHEL/统信: yum install -y acl"
echo " Debian/Ubuntu: apt-get install -y acl"
exit 1
}
WEB_USER=$(stat -c '%U' "$SITE")
echo "站点目录属主(web 用户):$WEB_USER"
echo
# ---------- 1. 创建用户 ----------
if id "$DEPLOY_USER" >/dev/null 2>&1; then
echo "[=] 用户 $DEPLOY_USER 已存在,跳过创建"
else
useradd -m -s /bin/bash "$DEPLOY_USER"
passwd -l "$DEPLOY_USER" >/dev/null 2>&1 || true
echo "[+] 已创建用户 $DEPLOY_USER(密码锁定,仅允许密钥登录)"
fi
for g in wheel sudo adm root; do
if id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$g"; then
gpasswd -d "$DEPLOY_USER" "$g" >/dev/null 2>&1 || true
echo "[!] 已从特权组 $g 中移除 $DEPLOY_USER"
fi
done
echo "[i] 当前所属组:$(id -nG "$DEPLOY_USER")"
# ---------- 2. 安装公钥 ----------
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys"
touch "$AUTH"
if grep -qF "$PUBKEY" "$AUTH"; then
echo "[=] 公钥已存在,跳过"
else
echo "$PUBKEY" >> "$AUTH"
echo "[+] 公钥已写入 authorized_keys"
fi
chmod 600 "$AUTH"
chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH"
chmod 700 "/home/$DEPLOY_USER"
echo
# ---------- 3. 上级目录穿透 ----------
for d in /www /www/wwwroot; do
if [ -d "$d" ]; then
other_bit=$(stat -c '%a' "$d"); other_bit=${other_bit: -1}
if [ "$other_bit" -lt 1 ]; then
setfacl -m "u:$DEPLOY_USER:x" "$d"
echo "[+] 已为 $d 补充 $DEPLOY_USER 的穿透(x)权限"
fi
fi
done
# ---------- 4. 目录授权(ACL,不改属主) ----------
# 站点根下可能有 root 属主文件(宝塔 .user.ini),setfacl -R 会报 EPERM,需要容错
acl_denied=""
setfacl -R -m "u:$DEPLOY_USER:rwX" "$SITE" 2>/dev/null || acl_denied="yes"
setfacl -R -d -m "u:$DEPLOY_USER:rwX" "$SITE" 2>/dev/null || acl_denied="yes"
if [ "$WEB_USER" != "root" ] && [ "$WEB_USER" != "UNKNOWN" ]; then
setfacl -R -m "u:$WEB_USER:rwX" "$SITE" 2>/dev/null || true
setfacl -R -d -m "u:$WEB_USER:rwX" "$SITE" 2>/dev/null || true
fi
if [ -n "$acl_denied" ]; then
find "$SITE" ! -user "$WEB_USER" -print0 2>/dev/null | while IFS= read -r -d '' p; do
printf " [跳过·属主非 %s] %s\n" "$WEB_USER" "$p"
done
echo "[!] 有部分条目未能设置 ACL(通常是 root 属主的 .user.ini,不影响部署)"
fi
echo "[+] 已对 $SITE 授予 $DEPLOY_USER 与 $WEB_USER 读写权限(属主未改动)"
echo
# ---------- 5. 预建应用可写目录 ----------
APP="$SITE/$APP_SUBDIR"
install -d -m 775 "$APP/data" "$APP/uploads/icons"
setfacl -R -m "u:$DEPLOY_USER:rwX,u:$WEB_USER:rwX" "$APP" 2>/dev/null || true
setfacl -R -d -m "u:$DEPLOY_USER:rwX,u:$WEB_USER:rwX" "$APP" 2>/dev/null || true
echo "[+] 已预建 $APP/{data,uploads/icons},并让 $DEPLOY_USER 与 $WEB_USER 可读写"
echo
# ---------- 6. 验收 ----------
echo "==================== 验收 ===================="
echo "用户信息: $(id "$DEPLOY_USER")"
echo "密钥文件: $(stat -c '%a %U:%G' "$AUTH")"
echo
echo -n "以 $DEPLOY_USER 身份在 $SITE 写测试: "
if su -s /bin/sh "$DEPLOY_USER" -c "touch '$SITE/.deploy_write_test' && rm -f '$SITE/.deploy_write_test'"; then
echo "通过"
else
echo "失败(请检查上面的 ACL 输出)"
fi
echo -n "以 $DEPLOY_USER 身份读 /etc/shadow: "
if su -s /bin/sh "$DEPLOY_USER" -c "cat /etc/shadow" >/dev/null 2>&1; then
echo "竟然可读(异常)"
else
echo "被拒绝(正常)"
fi
echo "=============================================="
echo
echo "完成后请把下面这行结果发我:"
echo " sudo -l -U $DEPLOY_USER"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+237
View File
@@ -0,0 +1,237 @@
<?php
/**
* 简航 SimpleNav —— 公共函数
*/
declare(strict_types=1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/db.php';
/** HTML 转义 */
function e(?string $s): string
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** 规范化 URL:补协议、去空白 */
function norm_url(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^https?://#i', $url)) {
$url = 'http://' . $url;
}
return $url;
}
/** 取主域名(用于 favicon 与文件名) */
function domain_of(string $url): string
{
$host = parse_url(norm_url($url), PHP_URL_HOST);
return $host ? preg_replace('/^www\./i', '', $host) : '';
}
/** curl 抓取文本(带 UA、超时、跟随跳转、限长) */
function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
if (!function_exists('curl_init')) {
// 兜底:allow_url_fopen
$ctx = stream_context_create(['http' => [
'timeout' => $timeout,
'header' => "User-Agent: Mozilla/5.0 SimpleNav\r\n",
'follow_location' => 1,
]]);
$body = @file_get_contents($url, false, $ctx, 0, $maxBytes);
if ($body === false) {
return null;
}
return ['body' => $body, 'url' => $url, 'type' => ''];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SimpleNav/1.0)',
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_ENCODING => '',
]);
$body = curl_exec($ch);
$type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$err = curl_errno($ch);
curl_close($ch);
if ($err || $body === false) {
return null;
}
return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type];
}
/**
* 抓取网站元信息:标题、名称、图标地址。
* @return array{title:string,name:string,icon:string,final_url:string}
*/
function fetch_site_meta(string $url): array
{
$url = norm_url($url);
$out = ['title' => '', 'name' => '', 'icon' => '', 'final_url' => $url];
$resp = http_get($url);
if ($resp === null) {
return $out;
}
$html = $resp['body'];
$final = $resp['url'];
$out['final_url'] = $final;
// 编码:优先按 <meta charset> 转换到 UTF-8
$html = ensure_utf8($html);
// <title>
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$out['title'] = trim(preg_replace('/\s+/', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')));
}
// og:site_name 优先作为"名称"
if (preg_match('#<meta[^>]+property=["\']og:site_name["\'][^>]+content=["\']([^"\']+)#i', $html, $m)) {
$out['name'] = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'));
} elseif ($out['title'] !== '') {
// 用标题的首段(按分隔符切分)作为名称
$t = preg_split('#[\|\-_—·::]#u', $out['title']);
$out['name'] = trim($t[0]);
}
// 图标:<link rel="...icon...">
$icon = '';
if (preg_match_all('#<link[^>]+>#i', $html, $links)) {
foreach ($links[0] as $tag) {
if (preg_match('#rel=["\'][^"\']*icon[^"\']*["\']#i', $tag)) {
if (preg_match('#href=["\']([^"\']+)["\']#i', $tag, $hm)) {
$cand = html_entity_decode($hm[1], ENT_QUOTES, 'UTF-8');
// 优先 apple-touch-icon(通常更大更清晰)
if (stripos($tag, 'apple-touch') !== false) {
$icon = $cand;
break;
}
if ($icon === '') {
$icon = $cand;
}
}
}
}
}
if ($icon !== '') {
$out['icon'] = absolutize($icon, $final);
} else {
$p = parse_url($final);
if (!empty($p['scheme']) && !empty($p['host'])) {
$out['icon'] = $p['scheme'] . '://' . $p['host'] . '/favicon.ico';
}
}
return $out;
}
/** 把可能为相对路径的 URL 补全为绝对 URL */
function absolutize(string $href, string $base): string
{
if (preg_match('#^https?://#i', $href)) {
return $href;
}
$p = parse_url($base);
$root = ($p['scheme'] ?? 'http') . '://' . ($p['host'] ?? '');
if (strpos($href, '//') === 0) {
return ($p['scheme'] ?? 'http') . ':' . $href;
}
if (strpos($href, '/') === 0) {
return $root . $href;
}
$dir = rtrim(dirname($p['path'] ?? '/'), '/');
return $root . $dir . '/' . $href;
}
/** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */
function ensure_utf8(string $s): string
{
if (preg_match('#<meta[^>]+charset=["\']?\s*(gbk|gb2312|gb18030)#i', $s)) {
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($s, 'UTF-8', 'GB18030');
}
}
if (function_exists('mb_check_encoding') && !mb_check_encoding($s, 'UTF-8')) {
return mb_convert_encoding($s, 'UTF-8', 'GB18030');
}
return $s;
}
/**
* 下载图标并保存到本地(PNG,64x64,透明背景)。
* @return string 本地文件名(如 ab12cd.png),失败返回 ''
*/
function download_icon(string $iconUrl, string $key, int $size = 64): string
{
if ($iconUrl === '') {
return '';
}
if (!is_dir(ICON_PATH)) {
@mkdir(ICON_PATH, 0755, true);
}
$resp = http_get($iconUrl, 500000, 8);
if ($resp === null || $resp['body'] === '') {
return '';
}
$bin = $resp['body'];
$name = md5($key) . '.png';
$dest = ICON_PATH . '/' . $name;
// 用 GD 统一转成 PNG(顺带裁成正方形、缩放)
if (function_exists('imagecreatefromstring')) {
$im = @imagecreatefromstring($bin);
if ($im !== false) {
$w = imagesx($im);
$h = imagesy($im);
$side = min($w, $h);
$square = imagecreatetruecolor($size, $size);
imagealphablending($square, false);
imagesavealpha($square, true);
$trans = imagecolorallocatealpha($square, 0, 0, 0, 127);
imagefill($square, 0, 0, $trans);
$sx = (int)(($w - $side) / 2);
$sy = (int)(($h - $side) / 2);
imagecopyresampled($square, $im, 0, 0, $sx, $sy, $size, $size, $side, $side);
imagepng($square, $dest);
imagedestroy($square);
imagedestroy($im);
return $name;
}
}
// 无 GD:原样保存(按 content-type 猜扩展名)
$ext = 'ico';
if (stripos($resp['type'], 'png') !== false) {
$ext = 'png';
} elseif (stripos($resp['type'], 'svg') !== false) {
$ext = 'svg';
} elseif (stripos($resp['type'], 'jpeg') !== false || stripos($resp['type'], 'jpg') !== false) {
$ext = 'jpg';
} elseif (stripos($resp['type'], 'gif') !== false) {
$ext = 'gif';
}
$name = md5($key) . '.' . $ext;
@file_put_contents(ICON_PATH . '/' . $name, $bin);
return is_file(ICON_PATH . '/' . $name) ? $name : '';
}
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
function icon_src(string $file): string
{
return $file === '' ? '' : url(ICON_URL . '/' . $file);
}
/** 名称首字配色(无图标时的回退头像底色) */
function letter_color(string $s): string
{
$palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
return $palette[abs(crc32($s)) % count($palette)];
}
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* 简航 SimpleNav —— 前台首页
* 顶部:搜索引擎切换 + 搜索框(带联想下拉)
* 中部:常用 / 工具等网格板块
* 下部:合集(标签页形式,点标签平铺该合集全部链接)
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
$pdo = db();
// 前台数据
$gridGroups = $pdo->query("SELECT * FROM groups WHERE is_visible=1 AND display='grid' ORDER BY sort_order, id")->fetchAll();
$tabGroups = $pdo->query("SELECT * FROM groups WHERE is_visible=1 AND display='tabs' ORDER BY sort_order, id")->fetchAll();
$engines = $pdo->query("SELECT * FROM engines WHERE is_active=1 ORDER BY sort_order, id")->fetchAll();
// 默认引擎
$defSlug = setting('default_engine', 'bing');
$default = null;
foreach ($engines as $en) {
if ($en['slug'] === $defSlug) { $default = $en; break; }
}
if ($default === null) { $default = $engines[0] ?? null; }
function sites_of(int $gid): array
{
$st = db()->prepare("SELECT * FROM sites WHERE group_id=? AND is_visible=1 ORDER BY sort_order, id");
$st->execute([$gid]);
return $st->fetchAll();
}
function site_label(array $s): string
{
return $s['short_name'] !== '' ? $s['short_name'] : ($s['name'] !== '' ? $s['name'] : domain_of($s['url']));
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?></title>
<meta name="theme-color" content="<?= e(setting('primary_color', '#2F6BF7')) ?>">
<link rel="icon" href="<?= e(url('favicon.ico')) ?>" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="<?= e(url('assets/icons/icon-32.png')) ?>">
<link rel="icon" type="image/png" sizes="16x16" href="<?= e(url('assets/icons/icon-16.png')) ?>">
<link rel="apple-touch-icon" sizes="180x180" href="<?= e(url('apple-touch-icon.png')) ?>">
<link rel="manifest" href="<?= e(url('site.webmanifest')) ?>">
<link rel="stylesheet" href="<?= e(url('assets/css/style.css')) ?>">
</head>
<body>
<div class="container">
<!-- 顶部搜索 -->
<div id="search" class="s-search">
<div class="search-type" id="engine-tabs">
<?php foreach ($engines as $i => $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e($en['search_url']) ?>"
data-param="<?= e($en['param']) ?>"
data-slug="<?= e($en['slug']) ?>"
data-placeholder="<?= e($en['name']) ?>搜索"
style="--ec:<?= e($en['color']) ?>">
<span><?= e($en['name']) ?></span>
</label>
<?php endforeach; ?>
<?php if (!$engines): ?>
<label class="s-engine active" data-search="https://cn.bing.com/search" data-param="q" data-slug="" data-placeholder="搜索"><span>搜索</span></label>
<?php endif; ?>
</div>
<form id="search-form" action="<?= e($default['search_url'] ?? 'https://cn.bing.com/search') ?>" method="get" target="<?= setting('open_blank', '1') === '1' ? '_blank' : '_self' ?>" rel="noopener">
<input type="text" id="search-text" name="<?= e($default['param'] ?? 'q') ?>"
placeholder="<?= e(($default['name'] ?? '搜索') . '搜索') ?>" autocomplete="off">
<button class="submit" type="submit" aria-label="搜索"></button>
<ul id="word"></ul>
</form>
</div>
<!-- 网格板块:常用 / 工具 ... -->
<?php foreach ($gridGroups as $g): $items = sites_of((int)$g['id']); if (!$items) continue; ?>
<ul class="mylist">
<li class="title"><?= e($g['name']) ?></li>
<?php foreach ($items as $s): $label = site_label($s); $src = icon_src($s['icon']); ?>
<li class="site">
<a href="<?= e($s['url']) ?>" target="_blank" rel="noopener" title="<?= e($s['name'] ?: $label) ?>">
<?php if ($src): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
<!-- 合集:标签页 -->
<?php if ($tabGroups): ?>
<div class="collections">
<div class="collections-title">合集</div>
<div class="tabs" id="coll-tabs">
<?php foreach ($tabGroups as $i => $g): ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" data-target="coll-<?= (int)$g['id'] ?>"><?= e($g['name']) ?></button>
<?php endforeach; ?>
</div>
<div class="tab-panels">
<?php foreach ($tabGroups as $i => $g): $items = sites_of((int)$g['id']); ?>
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$g['id'] ?>">
<?php foreach ($items as $s): $label = site_label($s); $src = icon_src($s['icon']); ?>
<li class="site">
<a href="<?= e($s['url']) ?>" target="_blank" rel="noopener" title="<?= e($s['name'] ?: $label) ?>">
<?php if ($src): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<footer class="foot">
<a href="<?= e(url('admin/')) ?>">管理</a>
<span>·</span>
<span><?= e(site_name()) ?></span>
</footer>
</div>
<script>window.SIMPLENAV = { suggest: "<?= e(url('suggest.php')) ?>" };</script>
<script src="<?= e(url('assets/js/app.js')) ?>"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 简航 SimpleNav —— 管理员登录 / 首次初始化
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$hasAdmin = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$u = trim((string)($_POST['username'] ?? ''));
$p = (string)($_POST['password'] ?? '');
if ($u === '' || $p === '') {
$err = '请输入用户名和密码';
} elseif (mb_strlen($p) < 4) {
$err = '密码至少 4 位';
} elseif (!$hasAdmin) {
// 首次创建管理员
$st = db()->prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)');
$st->execute([$u, password_hash($p, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
$_SESSION['uid'] = (int)db()->lastInsertId();
header('Location: ' . url('admin/'));
exit;
} else {
// 登录校验
$st = db()->prepare('SELECT * FROM users WHERE username=? LIMIT 1');
$st->execute([$u]);
$row = $st->fetch();
if ($row && password_verify($p, $row['password_hash'])) {
$_SESSION['uid'] = (int)$row['id'];
header('Location: ' . url('admin/'));
exit;
}
$err = '用户名或密码错误';
}
}
$title = $hasAdmin ? '登录' : '初始化管理员';
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?> · <?= e(site_name()) ?></title>
<link rel="stylesheet" href="<?= e(url('assets/css/style.css')) ?>">
<link rel="stylesheet" href="<?= e(url('assets/css/admin.css')) ?>">
</head>
<body class="admin">
<div class="login-wrap">
<form class="login-card" method="post" action="">
<img class="login-logo" src="<?= e(url('assets/img/logo.svg')) ?>" alt="" onerror="this.style.display='none'">
<h1><?= e(site_name()) ?></h1>
<p class="sub"><?= $hasAdmin ? '管理员登录' : '首次使用,请创建管理员账号' ?></p>
<?php if ($err): ?><div class="alert"><?= e($err) ?></div><?php endif; ?>
<input type="text" name="username" placeholder="用户名" autocomplete="username" required>
<input type="password" name="password" placeholder="密码" autocomplete="<?= $hasAdmin ? 'current-password' : 'new-password' ?>" required>
<button type="submit" class="btn primary block"><?= $hasAdmin ? '登录' : '创建并进入' ?></button>
</form>
</div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: ' . url('login.php'));
exit;
+15
View File
@@ -0,0 +1,15 @@
{
"name": "简航 SimpleNav",
"short_name": "简航",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#d8d9da",
"theme_color": "#2F6BF7",
"icons": [
{ "src": "assets/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "assets/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "assets/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "assets/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* 简航 SimpleNav —— 搜索联想代理
* 浏览器直接调用各搜索引擎的联想接口会有跨域/JSONP 限制,这里由服务端代理并统一输出 JSON。
* 用法:suggest.php?slug=bing&q=关键词
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$slug = isset($_GET['slug']) ? preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : '';
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
if ($q === '') {
echo json_encode(['q' => '', 'items' => []]);
exit;
}
$items = [];
try {
$st = db()->prepare('SELECT * FROM engines WHERE slug=? AND is_active=1 LIMIT 1');
$st->execute([$slug]);
$engine = $st->fetch();
if ($engine && $engine['suggest_url'] !== '') {
$u = str_replace('{q}', rawurlencode($q), $engine['suggest_url']);
$resp = http_get($u, 100000, 5);
if ($resp !== null) {
$items = parse_suggest($engine['suggest_type'], ensure_utf8($resp['body']), $q);
}
}
} catch (Throwable $e) {
$items = [];
}
// 去重、限长、裁剪
$items = array_values(array_unique(array_filter(array_map('trim', $items))));
$items = array_slice($items, 0, 10);
echo json_encode(['q' => $q, 'items' => $items], JSON_UNESCAPED_UNICODE);
exit;
/** 按引擎类型解析联想结果 */
function parse_suggest(string $type, string $body, string $q): array
{
$body = trim($body);
$out = [];
switch ($type) {
case 'bing_osjson':
case 'google_firefox':
// ["query", ["s1","s2",...]]
$j = json_decode($body, true);
if (is_array($j) && isset($j[1]) && is_array($j[1])) {
$out = $j[1];
}
break;
case 'baidu_sugrec':
// {"q":"x","g":[{"type":"..","q":".."}], ...}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['g']) && is_array($j['g'])) {
foreach ($j['g'] as $it) { if (isset($it['q'])) $out[] = $it['q']; }
} elseif (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
}
}
break;
case 'so360':
// {"s":[".."]} 或 {"result":[{"word":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['s']) && is_array($j['s'])) { $out = $j['s']; }
elseif (!empty($j['result']) && is_array($j['result'])) {
foreach ($j['result'] as $it) { if (isset($it['word'])) $out[] = $it['word']; }
}
}
break;
case 'sogou_ajaj':
// {"query":"x","items":["a","b"]}(可能是 GBK,已在上面转码)
$j = json_decode($body, true);
if (is_array($j) && !empty($j['items']) && is_array($j['items'])) {
$out = $j['items'];
} else {
// 兜底:正则抓引号字符串
if (preg_match_all('#"([^"]{1,40})"#u', $body, $m)) {
$out = array_slice($m[1], 1); // 跳过 query 自身
}
}
break;
case 'ddg':
// [{"phrase":".."},...]
$j = json_decode($body, true);
if (is_array($j)) {
foreach ($j as $it) {
if (is_array($it) && isset($it['phrase'])) { $out[] = $it['phrase']; }
elseif (is_string($it)) { $out[] = $it; }
}
}
break;
default:
// 通用兜底:尝试 JSON,再退化正则
$j = json_decode($body, true);
if (is_array($j)) {
if (isset($j[1]) && is_array($j[1])) { $out = $j[1]; }
elseif (!empty($j['s']) && is_array($j['s'])) { $out = $j['s']; }
}
}
return $out;
}
+12
View File
@@ -0,0 +1,12 @@
# 禁止在 uploads 目录执行脚本(只允许作为静态资源访问)
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|asp|jsp|sh)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
Options -ExecCGI
AddType text/plain .php .phtml .phar
View File