Files
FamilyBook/nav_show.php
T

298 lines
14 KiB
PHP
Raw Normal View History

<?php
/**
* 家书 FamilyBook · 个人导航**展示页**(v24 第 24 批 用户第 4 条)
*
* ⚠️ 这是全站**唯一不需要登录**的页面 —— 用户明确要求,而且理由很实在:
* 这个页面就是要"发给别人看 / 自己随手点开"的,要登录就没意义了。
*
* 用户原话:
* 「现在的系统,每一个功能模块都是需要登录的。但是导航展示页面却不需要登录。
* 这就意味着我们需要给他一个非常特殊的地址。地址后面可以跟着用户添加的一个短语,
* 就跟类似于一个密钥一样,他可以给自己起一个短语。然后这个地址后面跟着问号
* 带上这个短语的形式去进行展示。这样的话我们是为了区分不同的用户,他的不同的
* 导航页面,用户选了一个短语之后,通过这个短语,我们就能知道要访问的是哪一个
* 用户的面板,这个页面是不需要登录的。在这个页面里面要增加一个功能,右上角
* 增加一个功能,就是登录,点击这个页面之后再进入到我们现在这个系统就是家书的
* 这个系统的登录页面。否则的话,那个导航的展示的页面是跟之前的一样,是不需要
* 进行登录的。这样的话,导航的展示页面,导航页的展示页面上面的那个头像就
* 变成用户自己的头像就可以了。」
*
* ⇒ 地址形式:`https://<站点>/nav/?q=<短语>`(也兼容 `?<短语>`,见下面两种都收)
* · 短语由用户自己起(可一键换随机码),全局唯一,就是这页的"凭据"
* · 右上角恒有一个「登录」入口,点它进家书的登录页
* · 页面顶部的头像用**他自己**的头像
*
* ⚠️ 安全上的三条硬约束(这是全站唯一免登录页,必须想清楚):
* ① **只暴露这一个用户自己的导航数据** —— 所有查询都按短语解析出的 person_id 走,
* 不接任何来自 URL 的 id(否则 `?q=xxx&person=2` 就能翻别人家);
* ② **不返回任何身份/家庭信息** —— 只有导航本身 + 头像 + 站点名。
* 连"这是谁"都不主动说(头像已经够了);
* ③ 短语按固定形式校验(4~32 位字母数字_-),拿不到它就是 404(不提示"短语不存在"
* 之外的信息,避免成为枚举靶子)。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
// ---- 1. 取短语(两种写法都收:?q=xxx 与 ?xxx)----
//
// ⚠️ 为什么两种都收:用户描述的是「地址后面跟着问号带上这个短语」,
// 也就是 `/nav/?yangyq` 这种**不带参数名**的形式;而现代浏览器/分享卡片
// 更习惯 `?q=`。两种都认,用户怎么写都能进,省得他记规则。
$phrase = trim((string)($_GET['q'] ?? ''));
if ($phrase === '' && !empty($_SERVER['QUERY_STRING'])) {
// `?yangyq` → QUERY_STRING 就是 `yangyq`(没有 `=` 号)
$qs = (string)$_SERVER['QUERY_STRING'];
if (strpos($qs, '=') === false) {
$phrase = urldecode($qs);
}
}
$personId = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase);
// ---- 2. 找不到就是 404(不给多余信息)----
if ($personId <= 0) {
http_response_code(404);
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>没有这个导航页</title>
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#F7F4EF;color:#2C231D;
font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.b{max-width:420px;padding:28px 30px;background:#fff;border:1px solid rgba(44,35,29,.10);
border-radius:16px;box-shadow:0 4px 16px rgba(44,35,29,.08);text-align:center}
h1{margin:0 0 8px;font-size:18px}
p{margin:0 0 16px;color:#7E6B5A;font-size:14px}
a{display:inline-block;padding:8px 18px;border-radius:10px;background:#A84A28;color:#fff;
text-decoration:none;font-size:14px}
</style>
</head>
<body>
<div class="b">
<h1>没有这个导航页</h1>
<p>地址里的短语不对,或者主人换过短语了。<br>确认一下地址,或者找主人要新的。</p>
<a href="<?= e(Nav::abs('/' . url('auth/login'))) ?>">去家书登录</a>
</div>
</body>
</html>
<?php
exit;
}
// ---- 3. 取这个人的导航数据(**只这一份**)----
//
// ⚠️ 这里开始的所有查询都只认 $personId,不再接任何请求参数。
$gridGroups = Nav::groups($personId, 'grid', true);
$tabGroups = Nav::groups($personId, 'tabs', true);
$engines = Nav::engines($personId, true);
$settings = Nav::settings($personId);
$sitesOf = static function (int $gid) use ($personId): array {
return Nav::sites($personId, $gid);
};
$mePerson = \App\Person::find($personId);
/* 🔴🔴 展示页里的自引用地址**一律要绝对化**(v24 第 25 批 用户第 9 条修的真 bug)。
*
* 根因:家书的 `url()` 产出 `index.php?r=xxx`、`avatar_url()` 产出 `media.php?k=…`
* —— 都是**相对地址**。而本页在 `/nav/` 子目录下,浏览器会把它们拼成
* `/nav/index.php?…`、`/nav/media.php?…` —— 线上实测**全部 404**。
* 用户看到的正是这一串:头像不显示 + 点「登录」404 + 搜索联想请求也 404。
* 修法见 Nav::abs()(它解释了为什么只在这一页需要绝对化)。
*
* 头像另外有一层:`media.php` **要登录**、`uploads/` 又被 `Require all denied`
* (线上实测 403),免登录页两头都取不到 → 所以走专门开的 `nav_avatar.php`
* (只服务这个短语主人自己的头像)。没有头像时它 404,前端 `onerror` 换成默认剪影。 */
$meAvatar = $mePerson && trim((string)($mePerson['avatar'] ?? '')) !== ''
? Nav::abs('/nav_avatar.php') . '?p=' . rawurlencode($phrase)
: '';
$defaultAvatar = Nav::abs(asset('/assets/avatar-default.svg'));
$loginUrl = Nav::abs('/' . url('auth/login'));
$suggestUrl = Nav::abs('/nav_suggest.php') . '?p=' . rawurlencode($phrase);
// 展示页自己的标题(用设置里的站点名与副标题;副标题允许为空 → 不显示)
$navTitle = trim((string)($settings['site_name'] ?? '')) !== '' ? (string)$settings['site_name'] : '我的导航';
$navSubtitle = (string)($settings['subtitle'] ?? '');
$primary = Nav::safeColor((string)($settings['primary_color'] ?? ''), '#2F6BF7');
$collLayout = (string)($settings['collections_layout'] ?? 'tabs') === 'flat' ? 'flat' : 'tabs';
$openTarget = Nav::openTarget($personId);
$openMode = $openTarget === '_blank' ? 'blank' : 'self';
$suggestKeys = Nav::suggestKeys($personId);
$default = Nav::defaultEngine($personId, $engines);
/** 渲染一个站点格(与 SimpleNav 的 site_tile 同构) */
$siteTile = static function (array $s) use ($openTarget): void {
$label = Nav::siteLabel($s);
$src = Nav::iconUrl((string)($s['icon'] ?? ''));
?>
<li class="site">
<a href="<?= e((string)$s['url']) ?>" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
title="<?= e(trim((string)($s['name'] ?? '')) !== '' ? (string)$s['name'] : $label) ?>">
<?php if ($src !== ''): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(Nav::letterColor($label)) ?>"><?= e(mb_substr($label, 0, 1, 'UTF-8')) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php
};
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($navTitle) ?><?= $navSubtitle !== '' ? ' · ' . e($navSubtitle) : '' ?></title>
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="<?= e($primary) ?>">
<link rel="stylesheet" href="<?= e(Nav::abs(asset('/assets/css/nav-show.css'))) ?>">
<?php if ($primary !== '#2F6BF7'): ?>
<style>:root { --n-primary: <?= e($primary) ?>; }</style>
<?php endif; ?>
</head>
<body data-open="<?= e($openMode) ?>">
<div class="container">
<?php /* ⚠️ 右上角恒有「登录」入口(用户明确要求)。
它进的是**家书**的登录页,不是这个导航系统的登录页 ——
因为导航展示页本来就不需要登录,这个按钮是给"主人自己"用的便利入口。 */ ?>
<div class="topline">
<div class="who">
<img class="who-av" src="<?= e($meAvatar !== '' ? $meAvatar : $defaultAvatar) ?>"
alt="" onerror="this.onerror=null;this.src='<?= e($defaultAvatar) ?>';">
<span class="who-name"><?= e($navTitle) ?></span>
</div>
<a class="login-btn" href="<?= e($loginUrl) ?>">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.5 6.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v10A1.5 1.5 0 0 0 5 16.5h6a1.5 1.5 0 0 0 1.5-1.5v-1.5"/>
<path d="M8 10h9m0 0-2.5-2.5M17 10l-2.5 2.5"/>
</svg>
登录
</a>
</div>
<header class="hero">
<img class="logo" src="<?= e($meAvatar !== '' ? $meAvatar : $defaultAvatar) ?>"
alt="" onerror="this.onerror=null;this.src='<?= e($defaultAvatar) ?>';">
<h1><?= e($navTitle) ?></h1>
<?php if ($navSubtitle !== ''): ?>
<p><?= e($navSubtitle) ?></p>
<?php endif; ?>
</header>
<?php if ($engines): ?>
<div id="search" class="s-search">
<div class="search-type">
<?php foreach ($engines as $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e((string)$en['search_url']) ?>"
data-param="<?= e((string)$en['param']) ?>"
data-slug="<?= e((string)$en['slug']) ?>"
data-placeholder="<?= e((string)$en['name']) ?>搜索"
style="--ec:<?= e(Nav::safeColor((string)$en['color'], '#2F6BF7')) ?>">
<span><?= e((string)$en['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<form id="search-form" action="<?= e((string)($default['search_url'] ?? 'https://cn.bing.com/search')) ?>"
method="get" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
data-slug="<?= e((string)($default['slug'] ?? '')) ?>" data-keys="<?= e($suggestKeys) ?>">
<input type="text" id="search-text" name="<?= e((string)($default['param'] ?? 'q')) ?>"
placeholder="<?= e((string)($default['name'] ?? '搜索')) ?>搜索" autocomplete="off">
<button class="submit" type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.6-3.6"></path>
</svg>
</button>
<ul id="word"></ul>
</form>
</div>
<?php endif; ?>
<?php foreach ($gridGroups as $g): $items = $sitesOf((int)$g['id']); if (!$items) { continue; } ?>
<ul class="mylist">
<li class="title"><?= e((string)$g['name']) ?></li>
<?php foreach ($items as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
<?php
$collGroups = [];
foreach ($tabGroups as $g) {
$items = $sitesOf((int)$g['id']);
if ($items) { $collGroups[] = ['g' => $g, 'items' => $items]; }
}
?>
<?php if ($collGroups): ?>
<div class="collections<?= $collLayout === 'flat' ? ' flat' : '' ?>" data-layout="<?= e($collLayout) ?>">
<div class="collections-title">合集</div>
<?php if ($collLayout === 'flat'): ?>
<?php foreach ($collGroups as $c): ?>
<ul class="mylist">
<li class="title"><?= e((string)$c['g']['name']) ?></li>
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
<?php else: ?>
<div class="tabs" role="tablist">
<?php foreach ($collGroups as $i => $c): ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
data-target="coll-<?= (int)$c['g']['id'] ?>"><?= e((string)$c['g']['name']) ?></button>
<?php endforeach; ?>
</div>
<div class="tab-panels">
<?php foreach ($collGroups as $i => $c): ?>
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$c['g']['id'] ?>" role="tabpanel">
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ⚠️ 空态:主人**还没在导航管理页里加过东西**时(`Nav::ensureSeed` 只在管理页跑,
展示页故意**不写数据** —— 它是一个免登录页面,不该被任何拿到短语的人触发出写操作),
这里会一块内容都没有。空白的公开页看起来像"坏了",所以要给一句话。 */ ?>
<?php if (!$gridGroups && !$collGroups): ?>
<div class="nav-empty">
<p>这个导航页还是空的 —— 主人还没往里加东西。</p>
<p class="small muted">(去「导航」页加几个常用网站,这里就有了。)</p>
</div>
<?php endif; ?>
<footer class="foot">
<a href="<?= e($loginUrl) ?>">登录家书</a>
<span>·</span>
<span><?= e($navTitle) ?></span>
</footer>
</div>
<script>
/* 联想词来源:走家书自己的代理(不直接把第三方接口暴露给浏览器 ——
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。
⚠️ v24 第 25 批:地址必须用 Nav::abs() **绝对化** —— 本页在 /nav/ 下,
相对地址会被拼成 /nav/nav_suggest.php…(线上实测 404,下拉永远空)。 */
window.SIMPLENAV = {
suggest: <?= json_encode($suggestUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
suggestKeys: <?= json_encode($suggestKeys) ?>
};
</script>
<script src="<?= e(Nav::abs(asset('/assets/js/nav-show.js'))) ?>"></script>
</body>
</html>