Harden auth/vault path sanitization and clean WeKnora docs
This commit is contained in:
@@ -79,49 +79,77 @@
|
||||
return data && data.result != null ? data.result : data;
|
||||
}
|
||||
|
||||
function maskLabel(state) {
|
||||
if (!state || state.state === 'absent') return '(无)';
|
||||
if (state.state === 'masked') return '••••••••';
|
||||
if (state.state === 'revealed') return state.value || '';
|
||||
return '••••••••';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable helper for rendering a single secret row (password/apikey/token).
|
||||
* Used heavily by account secrets, nested secrets, and the second-secret bug fix.
|
||||
* Reduces duplication in render blocks.
|
||||
* Display label for a secret field state. Always HTML-escaped so callers can
|
||||
* safely inject into innerHTML (revealed values may contain user-controlled text).
|
||||
*/
|
||||
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) {
|
||||
var key = opts.secretId || '';
|
||||
function maskLabel(secretState) {
|
||||
if (!secretState || secretState.state === 'absent') return '(无)';
|
||||
if (secretState.state === 'masked') return '••••••••';
|
||||
if (secretState.state === 'revealed') return escapeHtml(secretState.value || '');
|
||||
return '••••••••';
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional reusable secret-row helper. Prefer bindWorkbench's secretRow for
|
||||
* production paths; this stays fail-safe if called with uiState in opts.
|
||||
* @param {object} [opts.uiState] workbench state with revealed / _revealedValues
|
||||
*/
|
||||
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts) {
|
||||
opts = opts || {};
|
||||
var uiState = opts.uiState || { revealed: {}, _revealedValues: {} };
|
||||
var revealed = uiState.revealed || {};
|
||||
var revealedValues = uiState._revealedValues || {};
|
||||
var key = opts.secretId || secretId || field || '';
|
||||
var has = secretState && secretState.state === 'masked';
|
||||
var canReveal = has || (secretState && secretState.state === 'revealed');
|
||||
if (!canReveal && !showEmpty) return '';
|
||||
|
||||
var shown = state.revealed[key]
|
||||
? state._revealedValues && state._revealedValues[key]
|
||||
? escapeHtml(state._revealedValues[key])
|
||||
: maskLabel(secretState)
|
||||
var isOpen = !!revealed[key];
|
||||
var shown = isOpen
|
||||
? (revealedValues[key] != null
|
||||
? escapeHtml(revealedValues[key])
|
||||
: maskLabel(secretState))
|
||||
: maskLabel(secretState);
|
||||
|
||||
var revealBtn = state.revealed[key] ? '隐藏' : '显示';
|
||||
var copyBtn = '<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' + escapeHtml(key) + '">复制</button>';
|
||||
var revealBtn = isOpen ? '隐藏' : '显示';
|
||||
var copyBtn =
|
||||
'<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' +
|
||||
escapeHtml(key) +
|
||||
'">复制</button>';
|
||||
var actions =
|
||||
'<button type="button" data-vault-reveal="" data-vault-secret-id="' +
|
||||
escapeHtml(secretId || '') +
|
||||
'" data-testid="vault-reveal-' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
revealBtn +
|
||||
'</button>' +
|
||||
copyBtn;
|
||||
|
||||
var actions = state.revealed[key]
|
||||
? '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn
|
||||
: '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn;
|
||||
|
||||
return '<div class="mnote-vault-field-row" data-vault-secret-row="' + escapeHtml(key) + '">'
|
||||
+ '<div class="mnote-vault-secret-value" data-testid="vault-secret-' + escapeHtml(key) + '">' + shown + '</div>'
|
||||
+ '<div class="mnote-vault-secret-actions">' + actions + '</div>'
|
||||
+ '</div>';
|
||||
return (
|
||||
'<div class="mnote-vault-field-row" data-vault-secret-row="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
'<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
shown +
|
||||
'</div>' +
|
||||
'<div class="mnote-vault-secret-actions">' +
|
||||
actions +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function bindWorkbench(root) {
|
||||
@@ -531,6 +559,10 @@
|
||||
badge.classList.remove('is-ai');
|
||||
}
|
||||
}
|
||||
var repairBtn = qs('[data-vault-repair-ai-folders]', root);
|
||||
if (repairBtn) {
|
||||
repairBtn.hidden = !state.isAiVault;
|
||||
}
|
||||
root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user');
|
||||
}
|
||||
|
||||
@@ -540,9 +572,9 @@
|
||||
statusEl.setAttribute('data-type', type || 'info');
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
async function confirmDiscard() {
|
||||
if (!state.dirty) return true;
|
||||
return window.confirm('有未保存的修改,确定放弃?');
|
||||
return await window.mnote.confirm('有未保存的修改,确定放弃?');
|
||||
}
|
||||
|
||||
function filteredItems() {
|
||||
@@ -840,6 +872,53 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* URLs for the edit form: keep empty draft rows (so "+ 网址" can add a blank
|
||||
* line). normalizeUrlsFromItem strips empties and would make add-url look dead.
|
||||
*/
|
||||
function urlsForFormEditor(item) {
|
||||
if (item && Array.isArray(item.urls) && item.urls.length) {
|
||||
return item.urls.map(function (u) {
|
||||
return u == null ? '' : String(u);
|
||||
});
|
||||
}
|
||||
var urls = normalizeUrlsFromItem(item);
|
||||
if (!urls.length) urls = [''];
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** Make a safe absolute href for external open (http/https only). */
|
||||
function externalUrlHref(raw) {
|
||||
var t = String(raw || '').trim();
|
||||
if (!t) return '';
|
||||
if (/^https?:\/\//i.test(t)) return t;
|
||||
if (/^\/\//.test(t)) return 'https:' + t;
|
||||
// Bare host/path: assume https (common for vault entries).
|
||||
if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}([/:].*)?$/i.test(t)) {
|
||||
return 'https://' + t;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** View-mode URL as hyperlink look; open only via Ctrl/Cmd+click (see click handler). */
|
||||
function urlLinkHtml(raw) {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) return '<span class="mnote-vault-muted">—</span>';
|
||||
var href = externalUrlHref(text);
|
||||
if (!href) {
|
||||
return '<span class="mnote-vault-field-value">' + escapeHtml(text) + '</span>';
|
||||
}
|
||||
return (
|
||||
'<a class="mnote-vault-url-link" href="' +
|
||||
escapeHtml(href) +
|
||||
'" data-vault-external-url="' +
|
||||
escapeHtml(href) +
|
||||
'" title="Ctrl/Cmd + 点击打开">' +
|
||||
escapeHtml(text) +
|
||||
'</a>'
|
||||
);
|
||||
}
|
||||
|
||||
function emptyForm(defaults) {
|
||||
defaults = defaults || {};
|
||||
var urls = normalizeUrlsFromItem(defaults);
|
||||
@@ -1381,20 +1460,14 @@
|
||||
if (urls.length) {
|
||||
urlsHtml = urls
|
||||
.map(function (u, idx) {
|
||||
var label = idx === 0 ? '主网址' : '备用 ' + idx;
|
||||
var link =
|
||||
'<a href="' +
|
||||
escapeHtml(u) +
|
||||
'" target="_blank" rel="noopener noreferrer">' +
|
||||
escapeHtml(u) +
|
||||
'</a>';
|
||||
var label = idx === 0 ? (urls.length > 1 ? '主网址' : '网址') : '备用 ' + idx;
|
||||
return (
|
||||
'<div class="mnote-vault-field-row">' +
|
||||
'<label>' +
|
||||
escapeHtml(label) +
|
||||
'</label>' +
|
||||
'<div class="mnote-vault-field-value">' +
|
||||
link +
|
||||
urlLinkHtml(u) +
|
||||
'</div></div>'
|
||||
);
|
||||
})
|
||||
@@ -1412,9 +1485,7 @@
|
||||
' 个</span></div>' +
|
||||
urlsHtml +
|
||||
'</div>'
|
||||
: urls.length === 1
|
||||
? fieldRow('网址', urls[0], showEmpty)
|
||||
: urlsHtml) +
|
||||
: urlsHtml) +
|
||||
'<div class="mnote-vault-section">' +
|
||||
'<div class="mnote-vault-section-head"><span>登录账号</span>' +
|
||||
(accounts.length > 1
|
||||
@@ -1722,8 +1793,8 @@
|
||||
var tagsStr = (item.tags || []).join(', ');
|
||||
// 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。
|
||||
var accounts = normalizeAccountsFromItem(item, true);
|
||||
var urls = normalizeUrlsFromItem(item);
|
||||
if (!urls.length) urls = [''];
|
||||
// Prefer draft rows (may include empty slots after "+ 网址"); do not strip blanks.
|
||||
var urls = urlsForFormEditor(item);
|
||||
state._formUrls = urls.slice();
|
||||
/**
|
||||
* Rebuild draft slots from item projection.
|
||||
@@ -2325,10 +2396,43 @@
|
||||
} else {
|
||||
renderDetail();
|
||||
}
|
||||
setStatus(
|
||||
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条',
|
||||
'success'
|
||||
var repair = result && result.folderRepair;
|
||||
var statusMsg =
|
||||
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条';
|
||||
if (repair && Number(repair.updated) > 0) {
|
||||
statusMsg += ' · 已修复分组 ' + repair.updated + ' 条';
|
||||
}
|
||||
setStatus(statusMsg, 'success');
|
||||
} catch (err) {
|
||||
setStatus(err.message || String(err), 'error');
|
||||
toast(err.message || String(err), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function repairAiFolders() {
|
||||
if (!state.isAiVault) return;
|
||||
// 你已清掉其它人的分享时,可指定默认分享者;留空则自动从 from-* / sharedFrom 推断
|
||||
var hint = window.prompt(
|
||||
'修复 AI 分组为「用户名/原分组」。\n' +
|
||||
'若库内只剩一人分享,可留空自动识别;否则填写分享者用户名(如 liaibo):',
|
||||
'liaibo'
|
||||
);
|
||||
if (hint === null) return;
|
||||
setStatus('正在修复 AI 分组…', 'info');
|
||||
try {
|
||||
var body = {};
|
||||
var actor = String(hint || '').trim();
|
||||
if (actor) body.defaultSourceActor = actor;
|
||||
var payload = await apiJson('POST', '/api/vault/ai/repair-folders', state.rootUri, body);
|
||||
var result = (payload && payload.result) || payload || {};
|
||||
var n = Number(result.updated || 0);
|
||||
toast(
|
||||
n > 0
|
||||
? '已修复 ' + n + ' 条 → 用户名/原分组(如 liaibo/系统)'
|
||||
: '无需修复(分组已是 用户名/原分组)',
|
||||
n > 0 ? 'success' : 'info'
|
||||
);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
setStatus(err.message || String(err), 'error');
|
||||
toast(err.message || String(err), 'error');
|
||||
@@ -2893,10 +2997,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener('click', function (ev) {
|
||||
root.addEventListener('click', async function (ev) {
|
||||
var t = ev.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
|
||||
// View-mode URL: look like a link, open only with Ctrl/Cmd+click.
|
||||
var urlLink = t.closest('a.mnote-vault-url-link, a[data-vault-external-url]');
|
||||
if (urlLink && root.contains(urlLink)) {
|
||||
var href =
|
||||
urlLink.getAttribute('data-vault-external-url') ||
|
||||
urlLink.getAttribute('href') ||
|
||||
'';
|
||||
if (!(ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (href) {
|
||||
try {
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
} catch (_e) {
|
||||
/* ignore popup block */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var toggle = t.closest('[data-vault-toggle-folder]');
|
||||
if (toggle && root.contains(toggle)) {
|
||||
var fpath = toggle.getAttribute('data-vault-toggle-folder') || '';
|
||||
@@ -3298,8 +3424,7 @@
|
||||
var shareTitle =
|
||||
(state.item && state.item.title) || state.selectedId;
|
||||
var alreadyShared = itemIsSharedToAi(state.item);
|
||||
if (
|
||||
!window.confirm(
|
||||
if (!(await window.mnote.confirm(
|
||||
alreadyShared
|
||||
? '将「' +
|
||||
shareTitle +
|
||||
@@ -3310,7 +3435,7 @@
|
||||
'」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' +
|
||||
'会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' +
|
||||
'密文 [Key] 模板原样复制,并自动同步缺失密文 key。'
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -3343,7 +3468,7 @@
|
||||
return loadList();
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
.catch(async function (err) {
|
||||
toast(err.message || String(err), 'error');
|
||||
setStatus(err.message || String(err), 'error');
|
||||
});
|
||||
@@ -3367,8 +3492,7 @@
|
||||
state.item.sharedToAi &&
|
||||
state.item.sharedToAi.targetActorId) ||
|
||||
'mnote-e2e';
|
||||
if (
|
||||
!window.confirm(
|
||||
if (!(await window.mnote.confirm(
|
||||
'撤销「' +
|
||||
unshareTitle +
|
||||
'」对 AI 密码本(' +
|
||||
@@ -3376,7 +3500,7 @@
|
||||
')的共享?\n' +
|
||||
'将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' +
|
||||
'原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。'
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -3414,6 +3538,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.closest('[data-vault-repair-ai-folders]')) {
|
||||
repairAiFolders();
|
||||
return;
|
||||
}
|
||||
if (t.closest('[data-vault-cipher-book]')) {
|
||||
if (!confirmDiscard()) return;
|
||||
state.mode = 'cipher';
|
||||
@@ -3472,7 +3600,7 @@
|
||||
delete state.cipherRevealed[ek];
|
||||
return loadCipherBook();
|
||||
})
|
||||
.catch(function (err) {
|
||||
.catch(async function (err) {
|
||||
toast(err.message || String(err), 'error');
|
||||
});
|
||||
return;
|
||||
@@ -3480,7 +3608,7 @@
|
||||
var cipherDel = t.closest('[data-vault-cipher-delete]');
|
||||
if (cipherDel) {
|
||||
var dk = cipherDel.getAttribute('data-vault-cipher-delete');
|
||||
if (!window.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。')) return;
|
||||
if (!(await window.mnote.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。'))) return;
|
||||
apiJson(
|
||||
'DELETE',
|
||||
'/api/vault/cipher-book/' + encodeURIComponent(dk),
|
||||
@@ -3570,27 +3698,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var clearNestedSec = t.closest('[data-vault-clear-sec-value]');
|
||||
if (clearNestedSec) {
|
||||
var csid = clearNestedSec.getAttribute('data-vault-clear-sec-value');
|
||||
var cAcc = clearNestedSec.getAttribute('data-acc-id');
|
||||
var formClearSec = qs('[data-vault-form]', detailEl);
|
||||
if (formClearSec) snapshotFormSlots(formClearSec);
|
||||
(state._formAccounts || []).forEach(function (acc) {
|
||||
if (cAcc && acc.id !== cAcc) return;
|
||||
(acc.secrets || []).forEach(function (sec) {
|
||||
if (sec.id !== csid) return;
|
||||
sec.valueClear = true;
|
||||
sec.valueText = '';
|
||||
sec.valueState = { state: 'absent' };
|
||||
});
|
||||
});
|
||||
state.dirty = true;
|
||||
reRenderFormKeepingSlots(state.mode === 'create');
|
||||
toast('密钥将在保存时清空', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
var clearBtn = t.closest('[data-vault-clear-secret]');
|
||||
if (clearBtn) {
|
||||
var cf = clearBtn.getAttribute('data-vault-clear-secret');
|
||||
@@ -3606,7 +3713,7 @@
|
||||
}
|
||||
|
||||
if (t.closest('[data-vault-delete]')) {
|
||||
if (!window.confirm('确定将条目移入密码箱回收站?')) return;
|
||||
if (!(await window.mnote.confirm('确定将条目移入密码箱回收站?'))) return;
|
||||
apiJson(
|
||||
'DELETE',
|
||||
'/api/vault/items/' + encodeURIComponent(state.selectedId),
|
||||
@@ -3637,13 +3744,13 @@
|
||||
if (tabDeleted) tabDeleted.classList.remove('is-active');
|
||||
return loadList();
|
||||
})
|
||||
.catch(function (err) {
|
||||
.catch(async function (err) {
|
||||
toast(err.message || String(err), 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (t.closest('[data-vault-purge]')) {
|
||||
if (!window.confirm('永久删除后无法恢复,确定?')) return;
|
||||
if (!(await window.mnote.confirm('永久删除后无法恢复,确定?'))) return;
|
||||
apiJson(
|
||||
'POST',
|
||||
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge',
|
||||
|
||||
Reference in New Issue
Block a user