feat(install): show the sync token on the install page

插件必须填 NAVI_SYNC_TOKEN,但页面上没有任何地方能看到它 —— 用户只能去翻
.env,实际就是配不起来。

- admin-server 新增 GET /admin/api/sync-info,返回 syncToken / hasToken。
  放在 /admin/ 下而不是 /api/ 下是必须的:插件上报用的 /api/history/sync
  被门户 auth_request 放行,若令牌挂在同一前缀下就等于公开。
  实测 /api/history/sync-info 返回 401,只有 /admin/api/sync-info 能取到。
- 安装引导页把「第一步」改为填写两项(服务地址 + 同步令牌),各自可复制;
  服务端未设令牌时提示留空即可;未登录时提示需先登录门户。
- README 补充对应说明。
This commit is contained in:
JianFeeeee
2026-09-11 11:49:31 +08:00
parent e409f967a9
commit de795de0fb
2 changed files with 76 additions and 9 deletions

View File

@ -27,6 +27,8 @@ var (
// 所以默认**不校验**;只有当你把容器端口直接暴露给不受信的网段、
// 而没有外层认证时才设 NAVI_ADMIN_REQUIRE_TOKEN=1。
requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN"))
// 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝)
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
// 本地上传的图标目录(Dashy 通过 /item-icons/ 读取)
iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons"))
// 壁纸目录(nginx 通过 /wallpapers/ 提供)
@ -456,6 +458,23 @@ func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "ok"})
}
// handleSyncInfo 把浏览器插件需要的接入信息给已登录用户。
//
// 为何可以在这里返回令牌:/admin/ 整体在门户登录墙之内;
// 而 /api/history/sync 是被门户放行的(否则插件无法上报),
// 所以这个 token 仍然是有意义的凭据,并且不能挂在 /api/ 下暴露。
func handleSyncInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, 405, "method not allowed")
return
}
jsonResp(w, map[string]any{
"syncToken": syncToken,
"hasToken": syncToken != "",
"requireAdminToken": requireTokenForWrite,
})
}
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
// 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。
func handlePageInfo(w http.ResponseWriter, r *http.Request) {
@ -628,6 +647,7 @@ func main() {
http.HandleFunc("/api/pageinfo", cors(handlePageInfo))
http.HandleFunc("/api/icon", cors(handleIconUpload))
http.HandleFunc("/api/wallpaper", cors(handleWallpaper))
http.HandleFunc("/api/sync-info", cors(handleSyncInfo))
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "saved"})
}))

View File

@ -42,6 +42,7 @@
.site input{flex:1;min-width:240px;background:#0b1220;border:1px solid #334155;border-radius:10px;
padding:9px 12px;color:#e2e8f0;font-size:14px}
.hint{font-size:13px;color:#94a3b8;margin-top:10px}
.lbl{display:block;font-size:12px;font-weight:600;color:#94a3b8;margin:12px 0 0}
.done{font-size:13px;color:#86efac;margin-top:8px}
</style>
</head>
@ -51,12 +52,22 @@
<p class="sub">安装后浏览记录会自动同步到本站,并据此把最常访问的服务排到「常用服务」最前面。</p>
<div class="card">
<h2>第一步:填写本站地址</h2>
<p class="hint">在插件设置里粘贴下面这个地址(点「复制」)。同步令牌从部署方的 <code>NAVI_SYNC_TOKEN</code> 环境变量获取。</p>
<h2>第一步:填写插件设置</h2>
<p class="hint">在插件设置里填下面两项,各自点「复制」再粘贴。</p>
<label class="lbl" for="site-url">服务地址</label>
<div class="site">
<input id="site-url" readonly>
<button class="btn" id="copy-btn" type="button">复制</button>
</div>
<label class="lbl" for="site-token">同步令牌</label>
<div class="site">
<input id="site-token" readonly placeholder="读取中…">
<button class="btn" id="copy-token" type="button">复制</button>
</div>
<p class="hint" id="token-note"></p>
<div class="done" id="copy-done" hidden>已复制</div>
</div>
@ -127,18 +138,54 @@
document.getElementById('site-url').value = location.origin;
document.getElementById('copy-btn').addEventListener('click', function () {
var input = document.getElementById('site-url');
var doneTimer = null;
function flashDone() {
var done = document.getElementById('copy-done');
done.hidden = false;
if (doneTimer) clearTimeout(doneTimer);
doneTimer = setTimeout(function () { done.hidden = true; }, 2000);
}
function copyFrom(inputId) {
var input = document.getElementById(inputId);
if (!input || !input.value) return;
input.select();
input.setSelectionRange(0, 999);
var done = document.getElementById('copy-done');
function ok() { done.hidden = false; setTimeout(function () { done.hidden = true; }, 2000); }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(input.value).then(ok, function () { document.execCommand('copy'); ok(); });
navigator.clipboard.writeText(input.value).then(flashDone, function () {
document.execCommand('copy'); flashDone();
});
} else {
document.execCommand('copy'); ok();
document.execCommand('copy'); flashDone();
}
});
}
document.getElementById('copy-btn').addEventListener('click', function () { copyFrom('site-url'); });
document.getElementById('copy-token').addEventListener('click', function () { copyFrom('site-token'); });
/* 同步令牌:由 admin-server 提供(/admin/ 在门户登录墙之内,
所以令牌不会出现在被门户放行的 /api/ 路径上)。 */
fetch('/admin/api/sync-info', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var tk = document.getElementById('site-token');
var note = document.getElementById('token-note');
if (d && d.hasToken) {
tk.value = d.syncToken;
note.textContent = '把这两项填进插件设置即可。';
} else if (d) {
tk.value = '';
tk.placeholder = '(服务端未设置令牌)';
document.getElementById('copy-token').hidden = true;
note.textContent = '服务端未配置 NAVI_SYNC_TOKEN,令牌留空即可。';
} else {
tk.placeholder = '无法读取,请确认已登录';
note.textContent = '需要登录门户后才能读取令牌。';
}
})
.catch(function () {
document.getElementById('site-token').placeholder = '无法读取,请确认已登录';
});
/* ── 浏览器识别 ──
UA 会互相包含(Edge/Brave/Opera 的 UA 里都有 Chrome/),所以要按