diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31bb55a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 运行时数据(数据库与抓取的图标不纳入版本库) +/data/* +!/data/.htaccess +!/data/index.html +/uploads/icons/* +!/uploads/icons/.gitkeep + +# 杂项 +*.db +*.log +.DS_Store +Thumbs.db diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..bb5d694 --- /dev/null +++ b/.htaccess @@ -0,0 +1,8 @@ +# SimpleNav —— 根目录安全规则 + + RewriteEngine On + RewriteRule ^data/ - [F,L] + RewriteRule ^\.git/ - [F,L] + +# 关闭目录浏览 +Options -Indexes diff --git a/README.md b/README.md index 8f77418..627be07 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ -# SimpleNav +# SimpleNav · 简航 -简航,一个面向个人的轻量级导航系统 \ No newline at end of file +简航(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 diff --git a/admin/_auth.php b/admin/_auth.php new file mode 100644 index 0000000..20c788e --- /dev/null +++ b/admin/_auth.php @@ -0,0 +1,93 @@ +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 ''; +} + +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'], + ]; + ?> + + + + +<?= e($title) ?> · <?= e(site_name()) ?> 后台 + + + + +
+
后台
+ +
+ + 退出 +
+
+
+ + + $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']]); +} diff --git a/admin/fetch_meta.php b/admin/fetch_meta.php new file mode 100644 index 0000000..c7ac78f --- /dev/null +++ b/admin/fetch_meta.php @@ -0,0 +1,36 @@ + 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); diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..ebfe52e --- /dev/null +++ b/admin/index.php @@ -0,0 +1,258 @@ +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 '
' . e($f['m']) . '
'; +} + +function toggle_form(string $action, int $id): string +{ + return '
' + . csrf_field() . '' + . '' + . '
'; +} + +function move_form(string $action, int $id, string $dir, string $label, string $p): string +{ + return '
' + . csrf_field() . '' + . '' + . '' + . '' + . '
'; +} + +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(); + ?> + + +
+
+

新增 / 编辑网站

+ +
+
+ + + + + + + + + +
+ + + +
+
+
+ +
+

网站列表()

+ + + + + + + + + + + + + + + + +
图标简称(展示)名称网址板块显示排序操作
+ +
+ + + +
+
+
+ + +
+

新增 / 编辑板块

+
+ + + + + + + +
+
+
+
+ + + + query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?> + + + + + + + + + + +
名称展示方式网站数显示排序操作
+ +
+ + +
+
+
+ + +
+

新增 / 编辑搜索引擎

+
+ + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + +
名称搜索地址联想类型启用排序操作
■ ()已启用' : '未启用' ?> + +
+ + +
+
+

提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。

+
+ + +
+

站点设置

+
+ + + + + + + +
+
+
+
+

修改管理员密码

+
+ + + + +
+
+
+ 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); diff --git a/apple-touch-icon.png b/apple-touch-icon.png new file mode 100644 index 0000000..84a91bc Binary files /dev/null and b/apple-touch-icon.png differ diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..9c24339 --- /dev/null +++ b/assets/css/admin.css @@ -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; } +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..2173971 --- /dev/null +++ b/assets/css/style.css @@ -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; } +} diff --git a/assets/icons/icon-128.png b/assets/icons/icon-128.png new file mode 100644 index 0000000..7040d6e Binary files /dev/null and b/assets/icons/icon-128.png differ diff --git a/assets/icons/icon-144.png b/assets/icons/icon-144.png new file mode 100644 index 0000000..178f521 Binary files /dev/null and b/assets/icons/icon-144.png differ diff --git a/assets/icons/icon-150.png b/assets/icons/icon-150.png new file mode 100644 index 0000000..aa0f3b4 Binary files /dev/null and b/assets/icons/icon-150.png differ diff --git a/assets/icons/icon-16.png b/assets/icons/icon-16.png new file mode 100644 index 0000000..811624c Binary files /dev/null and b/assets/icons/icon-16.png differ diff --git a/assets/icons/icon-180.png b/assets/icons/icon-180.png new file mode 100644 index 0000000..bd2ac31 Binary files /dev/null and b/assets/icons/icon-180.png differ diff --git a/assets/icons/icon-192-maskable.png b/assets/icons/icon-192-maskable.png new file mode 100644 index 0000000..c515e69 Binary files /dev/null and b/assets/icons/icon-192-maskable.png differ diff --git a/assets/icons/icon-192.png b/assets/icons/icon-192.png new file mode 100644 index 0000000..3ca3014 Binary files /dev/null and b/assets/icons/icon-192.png differ diff --git a/assets/icons/icon-256.png b/assets/icons/icon-256.png new file mode 100644 index 0000000..a4dea95 Binary files /dev/null and b/assets/icons/icon-256.png differ diff --git a/assets/icons/icon-32.png b/assets/icons/icon-32.png new file mode 100644 index 0000000..87b2c29 Binary files /dev/null and b/assets/icons/icon-32.png differ diff --git a/assets/icons/icon-384.png b/assets/icons/icon-384.png new file mode 100644 index 0000000..e9fa8d4 Binary files /dev/null and b/assets/icons/icon-384.png differ diff --git a/assets/icons/icon-48.png b/assets/icons/icon-48.png new file mode 100644 index 0000000..95e7b7f Binary files /dev/null and b/assets/icons/icon-48.png differ diff --git a/assets/icons/icon-512-maskable.png b/assets/icons/icon-512-maskable.png new file mode 100644 index 0000000..0736449 Binary files /dev/null and b/assets/icons/icon-512-maskable.png differ diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png new file mode 100644 index 0000000..56d7e9c Binary files /dev/null and b/assets/icons/icon-512.png differ diff --git a/assets/icons/icon-64.png b/assets/icons/icon-64.png new file mode 100644 index 0000000..eda8143 Binary files /dev/null and b/assets/icons/icon-64.png differ diff --git a/assets/icons/icon-96.png b/assets/icons/icon-96.png new file mode 100644 index 0000000..cdc260e Binary files /dev/null and b/assets/icons/icon-96.png differ diff --git a/assets/img/logo.svg b/assets/img/logo.svg new file mode 100644 index 0000000..3e7ebc8 --- /dev/null +++ b/assets/img/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..de155b1 --- /dev/null +++ b/assets/js/admin.js @@ -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(); + }); + } +})(); diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..a1f8428 --- /dev/null +++ b/assets/js/app.js @@ -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 '
  • ' + d.innerHTML + '
  • '; + }).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'); } + }); + }); +})(); diff --git a/config.php b/config.php new file mode 100644 index 0000000..9133982 --- /dev/null +++ b/config.php @@ -0,0 +1,60 @@ +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; +} diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..4628e4a --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,8 @@ +# 禁止 Web 访问 data 目录(SQLite 数据库等敏感文件) + + Require all denied + + + Order allow,deny + Deny from all + diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..7d6a4be --- /dev/null +++ b/data/index.html @@ -0,0 +1 @@ + diff --git a/db.php b/db.php new file mode 100644 index 0000000..8518f17 --- /dev/null +++ b/db.php @@ -0,0 +1,226 @@ +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); +} diff --git a/deploy/nginx-simplenav-deny-data.conf b/deploy/nginx-simplenav-deny-data.conf new file mode 100644 index 0000000..e990f2e --- /dev/null +++ b/deploy/nginx-simplenav-deny-data.conf @@ -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; } diff --git a/deploy/setup-simplenav-user.sh b/deploy/setup-simplenav-user.sh new file mode 100644 index 0000000..2548081 --- /dev/null +++ b/deploy/setup-simplenav-user.sh @@ -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" diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..4f2641f Binary files /dev/null and b/favicon.ico differ diff --git a/functions.php b/functions.php new file mode 100644 index 0000000..67305e9 --- /dev/null +++ b/functions.php @@ -0,0 +1,237 @@ + [ + '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; + + // 编码:优先按 转换到 UTF-8 + $html = ensure_utf8($html); + + // + if (preg_match('#<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('#]+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]); + } + + // 图标: + $icon = ''; + if (preg_match_all('#]+>#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('#]+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)]; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..7d5318a --- /dev/null +++ b/index.php @@ -0,0 +1,140 @@ +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'])); +} +?> + + + + + +<?= e(site_name()) ?> · <?= e(setting('subtitle', '个人网站导航')) ?> + + + + + + + + + +
    + + + + + + + + + + + +
    +
    合集
    +
    + $g): ?> + + +
    +
    + $g): $items = sites_of((int)$g['id']); ?> + + +
    +
    + + + +
    + + + + + diff --git a/login.php b/login.php new file mode 100644 index 0000000..9b19bb9 --- /dev/null +++ b/login.php @@ -0,0 +1,64 @@ +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 ? '登录' : '初始化管理员'; +?> + + + + +<?= e($title) ?> · <?= e(site_name()) ?> + + + + + + + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..e028230 --- /dev/null +++ b/logout.php @@ -0,0 +1,14 @@ + '', '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; +} diff --git a/uploads/.htaccess b/uploads/.htaccess new file mode 100644 index 0000000..7f135f3 --- /dev/null +++ b/uploads/.htaccess @@ -0,0 +1,12 @@ +# 禁止在 uploads 目录执行脚本(只允许作为静态资源访问) + + + Require all denied + + + Order allow,deny + Deny from all + + +Options -ExecCGI +AddType text/plain .php .phtml .phar diff --git a/uploads/icons/.gitkeep b/uploads/icons/.gitkeep new file mode 100644 index 0000000..e69de29