feat: 简航 SimpleNav 个人网站导航系统(PHP + SQLite)
- 前台:搜索引擎切换+联想下拉、网格板块(常用/工具)、合集标签页 - 后台:初始化/登录、网站/板块/引擎增删改、上下移排序 - 添加网站自动抓取名称与图标(存储于服务器),支持自定义简称 - 一键全部更新(批量刷新图标+名称,保留自定义简称) - 搜索联想服务端代理(必应/百度/谷歌/搜狗/360/DuckDuckGo) - 零依赖:原生 CSS/JS,PDO SQLite
@@ -0,0 +1,12 @@
|
||||
# 运行时数据(数据库与抓取的图标不纳入版本库)
|
||||
/data/*
|
||||
!/data/.htaccess
|
||||
!/data/index.html
|
||||
/uploads/icons/*
|
||||
!/uploads/icons/.gitkeep
|
||||
|
||||
# 杂项
|
||||
*.db
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,8 @@
|
||||
# SimpleNav —— 根目录安全规则
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteRule ^data/ - [F,L]
|
||||
RewriteRule ^\.git/ - [F,L]
|
||||
</IfModule>
|
||||
# 关闭目录浏览
|
||||
Options -Indexes
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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']]);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 615 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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 |
@@ -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();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<!-- 占位,防止目录被列出 -->
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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"
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -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)];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||