feat(meowpanel): 可选密码鉴权 (agent-196, 用户定案) — panel.auth_enabled(默认0)+panel.auth_pass(带盐sha256不明文); token会话(/dev/urandom,TTL12h,内存+卡持久化,静态屏蔽session文件); 未启用零回归,启用后 /api/* 全要会话; 前端登录页+401回跳+设置卡片; 顺带修 settings_write_one 容忍等号两侧空格(原会重复追加行)
meowpanel→f91856a4; 0 warning; 宿主curl 20例+headless Chromium 全过
This commit is contained in:
1 parent
5f7e7542bd
commit
feb3df681c
7 files changed
+701
-35
No files matched your search
@@ -100,6 +100,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录页(面板鉴权启用后显示) -->
|
||||
<div id="login" class="login-scrim" hidden>
|
||||
<div class="login-box">
|
||||
<div class="login-brand"><span class="login-brand-mark">M</span><span>MeowPanel</span></div>
|
||||
<p class="login-sub">此面板已开启密码鉴权,请输入密码后使用。</p>
|
||||
<input type="password" id="login-password" class="login-input" placeholder="面板密码" autocomplete="current-password">
|
||||
<p class="login-err" id="login-err" hidden></p>
|
||||
<button class="btn btn-primary login-btn" id="login-btn">登 录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置面板密码对话框 -->
|
||||
<div id="pwdlg" class="modal-scrim" hidden>
|
||||
<div class="modal">
|
||||
<h2 class="modal-title" id="pwdlg-title">设置面板密码</h2>
|
||||
<p class="modal-body" id="pwdlg-body">请输入新密码(留空则清除密码)。</p>
|
||||
<input type="password" id="pwdlg-input" class="login-input" placeholder="新密码" autocomplete="new-password">
|
||||
<div class="modal-actions">
|
||||
<button class="btn" id="pwdlg-cancel">取消</button>
|
||||
<button class="btn btn-primary" id="pwdlg-ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="meowpanel.js"></script>
|
||||
|
||||
@@ -289,6 +289,19 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
|
||||
.modal-body{ color:var(--on-surface-variant); margin:0 0 18px; word-break:break-all; }
|
||||
.modal-actions{ display:flex; justify-content:flex-end; gap:10px; }
|
||||
|
||||
/* 登录页(面板鉴权) */
|
||||
.login-scrim{ position:fixed; inset:0; z-index:70; background:var(--scrim); display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||
.login-scrim[hidden]{ display:none; }
|
||||
.login-box{ background:var(--surface); border-radius:var(--radius-l); width:min(360px,100%); padding:28px 26px; box-shadow:var(--shadow); border:var(--outline-w) solid var(--outline-variant); text-align:center; }
|
||||
.login-brand{ display:inline-flex; align-items:center; gap:8px; font-size:20px; font-weight:600; margin-bottom:6px; }
|
||||
.login-brand-mark{ width:30px; height:30px; border-radius:8px; background:var(--primary); color:var(--on-primary); display:inline-flex; align-items:center; justify-content:center; font-weight:700; }
|
||||
.login-sub{ color:var(--on-surface-variant); font-size:13px; margin:0 0 18px; }
|
||||
.login-input{ width:100%; padding:11px 12px; border-radius:var(--radius-m); border:var(--outline-w) solid var(--outline-variant); background:var(--surface); color:var(--on-surface); font-size:14px; margin-bottom:12px; box-sizing:border-box; }
|
||||
.login-input:focus{ outline:none; border-color:var(--primary); }
|
||||
.login-err{ color:var(--error); font-size:13px; margin:0 0 12px; text-align:left; }
|
||||
.login-btn{ display:block; width:100%; }
|
||||
.modal .login-input{ margin-top:4px; }
|
||||
|
||||
/* 空态 / 加载 */
|
||||
.empty{ text-align:center; color:var(--on-surface-variant); padding:48px 0; }
|
||||
.skel{ color:var(--on-surface-variant); }
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
const $ = (sel, root) => (root || document).querySelector(sel);
|
||||
const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel));
|
||||
|
||||
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||||
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||
function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
@@ -38,6 +42,7 @@
|
||||
filesRel: '/', filesAbs: null,
|
||||
perfTimer: null, // 性能页轮询
|
||||
route: 'perf',
|
||||
unauth: false, // 鉴权未通过(弹登录页)
|
||||
downloadNonce: 0
|
||||
};
|
||||
|
||||
@@ -54,13 +59,17 @@
|
||||
// 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连
|
||||
async function api(method, path, body) {
|
||||
let res;
|
||||
const headers = {};
|
||||
const token = storageGet('meowpanel.token');
|
||||
if (token) headers['X-MeowToken'] = token;
|
||||
const init = { method: method || 'GET' };
|
||||
if (body instanceof FormData) {
|
||||
init.body = body;
|
||||
} else if (body !== undefined && body !== null) {
|
||||
init.headers = { 'Content-Type': 'application/json' };
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
if (Object.keys(headers).length) init.headers = headers;
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
} catch (e) {
|
||||
@@ -77,6 +86,7 @@
|
||||
if (!json.ok) {
|
||||
setConn(true);
|
||||
const code = (json.error && json.error.code) || 'ERROR';
|
||||
if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页
|
||||
throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status };
|
||||
}
|
||||
setConn(true);
|
||||
@@ -86,7 +96,10 @@
|
||||
// 下载(返回 blob URL)
|
||||
async function apiDownload(path) {
|
||||
let res;
|
||||
try { res = await fetch(path); }
|
||||
const headers = {};
|
||||
const token = storageGet('meowpanel.token');
|
||||
if (token) headers['X-MeowToken'] = token;
|
||||
try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); }
|
||||
catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; }
|
||||
if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; }
|
||||
setConn(true);
|
||||
@@ -115,6 +128,65 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 鉴权(可选密码登录) ---------------- */
|
||||
function authRequired() { // 任一路由收到 401 → 弹登录页
|
||||
state.unauth = true;
|
||||
showLogin();
|
||||
}
|
||||
function showLogin() {
|
||||
state.unauth = true;
|
||||
const lg = $('#login'); if (lg) lg.hidden = false;
|
||||
const ip = $('#login-password'); if (ip) ip.focus();
|
||||
}
|
||||
function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; }
|
||||
async function doLogin() {
|
||||
const pw = $('#login-password').value;
|
||||
const err = $('#login-err');
|
||||
if (err) err.hidden = true;
|
||||
if (!pw) { if (err) { err.textContent = '请输入密码'; err.hidden = false; } return; }
|
||||
const btn = $('#login-btn'); if (btn) btn.disabled = true;
|
||||
try {
|
||||
const d = await api('POST', '/api/auth/login', { password: pw });
|
||||
storageSet('meowpanel.token', d.token);
|
||||
state.unauth = false;
|
||||
hideLogin();
|
||||
toast('已登录');
|
||||
if (started) route(); else startApp();
|
||||
} catch (e) {
|
||||
if (err) { err.textContent = (e && e.message) ? e.message : '登录失败'; err.hidden = false; }
|
||||
} finally {
|
||||
const b = $('#login-btn'); if (b) b.disabled = false;
|
||||
}
|
||||
}
|
||||
function bindLogin() {
|
||||
const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin);
|
||||
const ip = $('#login-password'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
}
|
||||
function passwordDlg(title, body) {
|
||||
return new Promise(res => {
|
||||
$('#pwdlg-title').textContent = title || '设置面板密码';
|
||||
$('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。';
|
||||
const inp = $('#pwdlg-input'); inp.value = ''; inp.focus();
|
||||
const scrim = $('#pwdlg'); scrim.hidden = false;
|
||||
const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel');
|
||||
const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); };
|
||||
okb.onclick = () => done(inp.value);
|
||||
cancel.onclick = () => done(null);
|
||||
scrim.onclick = e => { if (e.target === scrim) done(null); };
|
||||
inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); };
|
||||
});
|
||||
}
|
||||
async function setPanelPassword() {
|
||||
const np = await passwordDlg('设置面板密码', '请输入新密码(留空则清除密码)。');
|
||||
if (np === null) return; // 取消
|
||||
try {
|
||||
await api('PUT', '/api/settings/panel.auth_pass', { value: np });
|
||||
toast(np ? '面板密码已设置' : '面板密码已清除');
|
||||
renderSettings();
|
||||
} catch (e) { toast('设置失败:' + errText(e)); }
|
||||
}
|
||||
function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ }
|
||||
|
||||
/* ---------------- 主题(MD3/MIUIx × 深/浅) ---------------- */
|
||||
function applyTheme(style, theme) {
|
||||
state.style = style; state.theme = theme;
|
||||
@@ -487,6 +559,11 @@
|
||||
/* ---------------- 5. 设置镜像页 ---------------- */
|
||||
function settingControl(it) {
|
||||
const key = it.key;
|
||||
if (key === 'panel.auth_pass') {
|
||||
const txt = it.set ? '已设置' : '未设置';
|
||||
return '<div class="field-row"><div style="flex:1" class="field-label">面板密码<span class="muted">(' + txt + ')</span></div>' +
|
||||
'<button class="btn btn-primary" data-skey="panel.auth_pass" data-stype="pass">' + (it.set ? '修改密码' : '设置密码') + '</button></div>';
|
||||
}
|
||||
if (it.type === 'bool') {
|
||||
return '<div class="field-row"><div style="flex:1" class="field-label"><div style="margin:0">' + esc(key) + '</div></div>' +
|
||||
'<label class="switch"><input type="checkbox" data-skey="' + esc(key) + '" data-stype="bool" ' + (it.value ? 'checked' : '') + '>' +
|
||||
@@ -515,11 +592,18 @@
|
||||
'<button class="btn btn-danger" id="set-restore">恢复全部默认</button>' +
|
||||
'<button class="btn" id="set-refresh">刷新</button></div>';
|
||||
const items = d.items || [];
|
||||
// 面板鉴权(可选防护)单独一卡
|
||||
const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass');
|
||||
if (authItems.length) {
|
||||
h += '<div class="card"><h3 class="card-title">面板鉴权(可选防护)</h3>' + authItems.map(settingControl).join('') +
|
||||
'<p class="muted" style="margin-top:8px">开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。</p></div>';
|
||||
}
|
||||
const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass');
|
||||
// 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看
|
||||
const groups = [['开关/布尔', items.filter(x => x.type === 'bool')],
|
||||
['数值/滑条', items.filter(x => x.type === 'int')],
|
||||
['枚举/选择', items.filter(x => x.type === 'enum')],
|
||||
['字符串/路径', items.filter(x => x.type === 'str')]];
|
||||
const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')],
|
||||
['数值/滑条', rest.filter(x => x.type === 'int')],
|
||||
['枚举/选择', rest.filter(x => x.type === 'enum')],
|
||||
['字符串/路径', rest.filter(x => x.type === 'str')]];
|
||||
groups.forEach(function (g) {
|
||||
if (!g[1].length) return;
|
||||
h += '<div class="card"><h3 class="card-title">' + g[0] + '</h3>' + g[1].map(settingControl).join('') + '</div>';
|
||||
@@ -542,6 +626,7 @@
|
||||
else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str');
|
||||
});
|
||||
c.addEventListener('click', async function (e) {
|
||||
if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; }
|
||||
if (e.target.closest('#set-restore')) {
|
||||
const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true);
|
||||
if (!ok) return;
|
||||
@@ -738,6 +823,7 @@
|
||||
$('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open'));
|
||||
}
|
||||
function route() {
|
||||
if (state.unauth) { showLogin(); return; } // 未认证 → 登录页
|
||||
const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf';
|
||||
const r = ROUTES[id] || ROUTES.perf;
|
||||
state.route = id;
|
||||
@@ -748,14 +834,26 @@
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
function init() {
|
||||
bindNav();
|
||||
bindThemeUi();
|
||||
bindSlider();
|
||||
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
|
||||
let started = false;
|
||||
function startApp() {
|
||||
if (started) { route(); return; }
|
||||
started = true;
|
||||
initTheme();
|
||||
window.addEventListener('hashchange', route);
|
||||
route();
|
||||
}
|
||||
async function init() {
|
||||
bindNav();
|
||||
bindThemeUi();
|
||||
bindSlider();
|
||||
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
|
||||
bindLogin();
|
||||
bindPwDlg();
|
||||
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
|
||||
let st = null;
|
||||
try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; }
|
||||
if (st && st.auth_enabled && !st.session) { showLogin(); return; }
|
||||
startApp();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
Reference in new issue
Block a user