Files
mnote/extensions/mnote-vault/content.js
T

656 lines
23 KiB
JavaScript
Raw Normal View History

/**
* Content script: detect password form submit, confirm UI, message background.
* Does NOT hold tokens or cookie secrets.
*
* KeePassXC-style flow:
* - submit / submit-button click → PENDING_SAVE (background)
* - after navigation, background opens OPEN_SAVE_UI_WITH_DRAFT
* - SPA same-page: background spa fallback also opens draft UI
* - blur is NOT a primary save trigger
*/
(() => {
if (window.__mnoteVaultContentInstalled) return;
window.__mnoteVaultContentInstalled = true;
const HOST_ID = "mnote-vault-save-host";
/** Last known password on this document only (lost on full navigation). */
let lastPassword = "";
let lastUsername = "";
let lastEmail = "";
/** Prevent double PENDING_SAVE from submit + button click. */
let submitLockUntil = 0;
const SUBMIT_LOCK_MS = 1200;
function findUsernameNear(passwordInput) {
const form =
passwordInput?.form || passwordInput?.closest?.("form") || document;
const candidates = form.querySelectorAll(
'input[type="email"], input[type="text"], input[name*="user" i], input[name*="login" i], input[name*="email" i], input[autocomplete="username"], input[autocomplete="email"]'
);
for (const el of candidates) {
if (el === passwordInput) continue;
if (el.type === "password" || el.type === "hidden") continue;
const v = (el.value || "").trim();
if (v) return { username: v, email: el.type === "email" ? v : "" };
}
// Fallback: any non-empty text-like input in form
const more = form.querySelectorAll(
"input:not([type]), input[type=text], input[type=email], input[type=tel]"
);
for (const el of more) {
if (el.type === "password" || el.type === "hidden") continue;
const v = (el.value || "").trim();
if (v) return { username: v, email: el.type === "email" ? v : "" };
}
return { username: "", email: "" };
}
function findPasswordIn(root) {
const scope = root || document;
const list = scope.querySelectorAll?.('input[type="password"]') || [];
for (const el of list) {
if (el.value && el.value.length >= 1) return el;
}
return list[0] || null;
}
function isRegisterPath() {
const p = location.pathname.toLowerCase();
return /sign[-_]?up|register|signup|join|create[-_]?account/.test(p);
}
function buildTitle() {
let title = (document.title || "").slice(0, 80);
if (isRegisterPath() && title && !/注册|register|sign.?up/i.test(title)) {
title = `注册 · ${title}`;
}
return title || location.hostname;
}
function collectDraftFromDom(preferPasswordInput) {
const pwEl =
preferPasswordInput ||
(document.activeElement?.type === "password"
? document.activeElement
: null) ||
findPasswordIn(document);
const pw = (pwEl && pwEl.value) || lastPassword || "";
const near = pwEl
? findUsernameNear(pwEl)
: { username: lastUsername, email: lastEmail };
const username = (near.username || lastUsername || "").trim();
const email = (near.email || lastEmail || "").trim();
if (pw) lastPassword = pw;
if (username) lastUsername = username;
if (email) lastEmail = email;
return {
pageUrl: location.href,
title: buildTitle(),
username,
email,
password: pw,
};
}
function removeHost() {
const el = document.getElementById(HOST_ID);
if (el) el.remove();
}
function sendPending(draft) {
if (!draft?.password) return;
const now = Date.now();
if (now < submitLockUntil) return;
submitLockUntil = now + SUBMIT_LOCK_MS;
lastPassword = draft.password;
if (draft.username) lastUsername = draft.username;
if (draft.email) lastEmail = draft.email;
chrome.runtime.sendMessage(
{
type: "PENDING_SAVE",
pageUrl: draft.pageUrl || location.href,
title: draft.title || buildTitle(),
username: draft.username || "",
password: draft.password,
email: draft.email || "",
fromSubmit: true,
},
() => {
// ignore response; SW may be waking
void chrome.runtime.lastError;
}
);
}
/**
* @param {object} draft
* @param {{ fromPending?: boolean }} [opts]
*/
async function showConfirm(draft, opts = {}) {
removeHost();
const host = document.createElement("div");
host.id = HOST_ID;
host.style.cssText =
"all:initial;position:fixed;z-index:2147483646;right:16px;bottom:16px;font-family:system-ui,sans-serif;";
const shadow = host.attachShadow({ mode: "closed" });
const style = document.createElement("style");
style.textContent = `
.card {
width: 320px; max-width: 92vw;
background: #1a1d23; color: #e8eaed;
border: 1px solid #3a3f4b; border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.45);
padding: 14px 14px 12px; font-size: 13px; line-height: 1.4;
}
h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
label { display:block; margin: 8px 0 2px; color:#9aa0a6; font-size:11px; }
input[type=text], input[type=password] {
width: 100%; box-sizing: border-box;
background:#0f1115; color:#e8eaed; border:1px solid #3a3f4b;
border-radius:6px; padding:6px 8px; font-size:13px;
}
.pw-wrap {
display:flex; align-items:stretch; gap:0;
border:1px solid #3a3f4b; border-radius:6px; overflow:hidden;
background:#0f1115;
}
.pw-wrap input {
flex:1; min-width:0; border:0; border-radius:0; padding:6px 8px;
}
.pw-wrap input:focus { outline:none; }
.btn-pw-toggle {
flex:0 0 auto; min-width:52px; margin:0; border:0; border-left:1px solid #3a3f4b;
border-radius:0; background:#2a2f3a; color:#e8eaed; padding:0 10px;
font-size:11px; cursor:pointer; line-height:1;
}
.btn-pw-toggle:hover { background:#343b49; }
.btn-pw-toggle:focus-visible { outline:2px solid #3b82f6; outline-offset:-2px; }
.row { display:flex; gap:8px; align-items:center; margin-top:8px; font-size:12px; }
.row input { width:auto; }
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px; }
button {
border:0; border-radius:8px; padding:7px 12px; cursor:pointer; font-size:12px;
}
.btn-cancel { background:#2a2f3a; color:#e8eaed; }
.btn-save { background:#3b82f6; color:#fff; font-weight:600; }
.btn-save:disabled { opacity:.5; cursor:wait; }
.msg { margin-top:8px; font-size:11px; color:#9aa0a6; min-height:14px; }
.msg.err { color:#f87171; }
.msg.ok { color:#4ade80; }
select {
width:100%; box-sizing:border-box; background:#0f1115; color:#e8eaed;
border:1px solid #3a3f4b; border-radius:6px; padding:6px 8px;
}
.muted { color:#9aa0a6; font-size:11px; margin-top:4px; }
.banner {
margin: 0 0 8px; padding: 6px 8px; border-radius: 6px;
background: #243044; color: #93c5fd; font-size: 11px;
}
.folder-new {
display:none; margin-top:6px;
}
.folder-new.is-open { display:block; }
.folder-hint { margin-top:4px; }
`;
const fromPending = Boolean(draft.fromPending || opts.fromPending);
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
<h3>保存到 MNote 密码箱</h3>
${
fromPending
? `<div class="banner">已捕获登录提交${draft.pendingReason === "nav_complete" ? "(登录后页面)" : ""} · 请确认后保存</div>`
: ""
}
<label>标题</label>
<input type="text" id="title" />
<label>URL</label>
<input type="text" id="url" />
<label>用户名</label>
<input type="text" id="username" autocomplete="off" />
<label>密码</label>
<div class="pw-wrap">
<input type="password" id="password" autocomplete="off" />
<button type="button" class="btn-pw-toggle" id="togglePw" aria-label="显示密码" title="显示/隐藏密码">显示</button>
</div>
<label>分组</label>
<select id="folder">
<option value="">(无分组)</option>
<option value="__new__"> 新建分组…</option>
</select>
<div class="folder-new" id="folderNewWrap">
<label>新分组名(可用 / 分层,如 工作/客户)</label>
<input type="text" id="folderNew" placeholder="例如:个人 / 工作 / 客户A" autocomplete="off" />
</div>
<div class="muted folder-hint" id="folderHint">读取已有分组…</div>
<label>匹配已有条目</label>
<select id="match">
<option value="create">新建条目</option>
</select>
<div class="row">
<input type="checkbox" id="saveSession" checked />
<label for="saveSession" style="margin:0;color:#e8eaed">同时保存登录态(Cookie</label>
</div>
<div class="row">
<input type="checkbox" id="shareAi" />
<label for="shareAi" style="margin:0;color:#e8eaed">同步到 AI 密码本</label>
</div>
<div class="row">
<input type="checkbox" id="neverAsk" />
<label for="neverAsk" style="margin:0;color:#e8eaed">此网站不再询问</label>
</div>
<div class="muted" id="cookieHint"></div>
<div class="msg" id="msg"></div>
<div class="actions">
<button type="button" class="btn-cancel" id="cancel">取消</button>
<button type="button" class="btn-save" id="save">保存</button>
</div>
`;
shadow.appendChild(style);
shadow.appendChild(card);
document.documentElement.appendChild(host);
const $ = (id) => shadow.getElementById(id);
$("title").value = draft.title || buildTitle();
$("url").value = draft.pageUrl || location.href;
$("username").value = draft.username || lastUsername || "";
$("password").value = draft.password || lastPassword || "";
function syncFolderNewVisibility() {
const isNew = $("folder").value === "__new__";
$("folderNewWrap").classList.toggle("is-open", isNew);
if (isNew) {
try {
$("folderNew").focus();
} catch {
/* ignore */
}
}
}
function resolveFolderPath() {
const sel = $("folder").value;
if (sel === "__new__") {
return String($("folderNew").value || "").trim();
}
return String(sel || "").trim();
}
$("folder").onchange = syncFolderNewVisibility;
$("togglePw").onclick = () => {
const input = $("password");
const btn = $("togglePw");
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "隐藏" : "显示";
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
};
// status + match
chrome.runtime.sendMessage({ type: "GET_STATUS" }, (st) => {
void chrome.runtime.lastError;
const r = st?.result || st;
if (!st?.ok || !r?.connected) {
$("msg").className = "msg err";
$("msg").textContent = "未连接:请打开扩展 Options 登录 MNote";
$("save").disabled = true;
}
});
const matchUrl = $("url").value.trim() || location.href;
chrome.runtime.sendMessage(
{ type: "MATCH_URL", pageUrl: matchUrl },
(res) => {
void chrome.runtime.lastError;
const items = res?.result?.items || [];
const sel = $("match");
for (const it of items) {
const opt = document.createElement("option");
opt.value = it.id;
opt.textContent = `追加到 · ${it.title || it.id}`;
sel.appendChild(opt);
}
}
);
// 已有分组 + 上次选用
chrome.runtime.sendMessage({ type: "LIST_FOLDERS" }, (res) => {
void chrome.runtime.lastError;
const r = res?.result || res || {};
const folders = Array.isArray(r.folders) ? r.folders : [];
const last = String(r.lastFolderPath || "").trim();
const sel = $("folder");
// 保留「无分组」「新建」两端选项,中间插入已有路径
const newOpt = sel.querySelector('option[value="__new__"]');
for (const fp of folders) {
const opt = document.createElement("option");
opt.value = fp;
opt.textContent = fp;
sel.insertBefore(opt, newOpt);
}
if (last) {
// 上次路径若不在列表中,也加一条再选中
let found = false;
for (const o of sel.options) {
if (o.value === last) {
found = true;
break;
}
}
if (!found) {
const opt = document.createElement("option");
opt.value = last;
opt.textContent = last;
sel.insertBefore(opt, newOpt);
}
sel.value = last;
} else {
sel.value = "";
}
syncFolderNewVisibility();
$("folderHint").textContent = folders.length
? `共 ${folders.length} 个已有分组;可选已有或新建`
: "暂无已有分组,可选择「新建分组」并填写名称";
});
// Request broad host access so cookies.getAll can see session cookies.
// Best-effort here; the critical grant happens on Save click (user gesture).
chrome.runtime.sendMessage(
{ type: "ENSURE_PAGE_HOST", pageUrl: matchUrl, broad: true },
() => {
void chrome.runtime.lastError;
chrome.runtime.sendMessage(
{ type: "CAPTURE_COOKIE_COUNT", pageUrl: matchUrl },
(res) => {
void chrome.runtime.lastError;
const n = res?.result?.count ?? 0;
const err = res?.result?.error;
if (n > 0) {
$("cookieHint").textContent = `将捕获约 ${n} 条 Cookie`;
} else if (err) {
$("cookieHint").textContent =
`无法读取 Cookie${err}。点「保存」时会再次申请网站权限;也可在扩展详情设为「所有网站」。`;
} else {
$("cookieHint").textContent =
"当前可能尚无会话 Cookie。点「保存」时会申请 Cookie 权限并捕获;若仍为 0,请检查扩展网站访问权限。";
}
}
);
}
);
$("cancel").onclick = () => {
const forever = Boolean($("neverAsk")?.checked);
chrome.runtime.sendMessage(
{
type: "DISMISS_SAVE",
pageUrl: $("url").value.trim() || location.href,
username: $("username").value.trim(),
forever,
},
() => {
void chrome.runtime.lastError;
removeHost();
}
);
};
$("save").onclick = async () => {
$("save").disabled = true;
$("msg").className = "msg";
$("msg").textContent = "保存中…";
const matchVal = $("match").value;
const folderPath = resolveFolderPath();
if ($("folder").value === "__new__" && !folderPath) {
$("msg").className = "msg err";
$("msg").textContent = "请填写新分组名称";
$("save").disabled = false;
return;
}
// Critical: request cookie host permission in this click gesture.
// Without it, chrome.cookies.getAll returns [] and login session cannot be saved.
let hostPermissionGranted = false;
try {
hostPermissionGranted = await new Promise((resolve) => {
chrome.runtime.sendMessage(
{
type: "ENSURE_PAGE_HOST",
pageUrl: $("url").value.trim() || location.href,
broad: true,
},
(res) => {
void chrome.runtime.lastError;
resolve(Boolean(res?.ok || res?.result?.ok || res?.result?.hasHost));
}
);
});
} catch {
hostPermissionGranted = false;
}
const payload = {
pageUrl: $("url").value.trim() || location.href,
title: $("title").value.trim(),
username: $("username").value.trim(),
password: $("password").value,
email: draft.email || lastEmail || "",
folderPath,
saveSession: $("saveSession").checked,
shareToAi: $("shareAi").checked,
mode: matchVal === "create" ? "create" : "append",
existingId: matchVal === "create" ? null : matchVal,
recaptureSession: true,
hostPermissionGranted,
};
chrome.runtime.sendMessage(
{ type: "SAVE_CREDENTIAL", payload },
(res) => {
void chrome.runtime.lastError;
if (!res?.ok) {
$("msg").className = "msg err";
$("msg").textContent = res?.error || "保存失败";
$("save").disabled = false;
return;
}
const r = res.result || {};
let text = `已保存 ${r.credentialId || ""}`;
if (folderPath && matchVal === "create") text += ` · 分组 ${folderPath}`;
if (r.sessionError) text += ` · 登录态: ${r.sessionError}`;
else if (payload.saveSession && r.sessionSaved) text += " · 含登录态";
else if (payload.saveSession) text += " · 登录态待二次捕获";
if (r.sessionRecaptureScheduled && !r.sessionSaved) {
text += " · 将二次捕获 Cookie";
}
$("msg").className = "msg ok";
$("msg").textContent = text;
setTimeout(removeHost, r.sessionError ? 3200 : 1800);
}
);
};
}
/**
* Manual toolbar open: prefer pending draft, else live form / last known.
*/
function openFromManual() {
chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => {
void chrome.runtime.lastError;
const pending = res?.result?.pending;
if (pending?.password) {
showConfirm(
{
pageUrl: pending.pageUrl || location.href,
title: pending.title || buildTitle(),
username: pending.username || "",
email: pending.email || "",
password: pending.password,
fromPending: true,
pendingReason: "manual",
},
{ fromPending: true }
);
return;
}
const draft = collectDraftFromDom();
showConfirm(draft);
});
}
function onSubmit(e) {
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
const pw = findPasswordIn(form) || findPasswordIn(document);
if (!pw || !pw.value) return;
const draft = collectDraftFromDom(pw);
if (!draft.password) return;
// Do not block navigation; stash in background immediately.
sendPending(draft);
}
/**
* Capture click on submit-like controls (including formless login UIs).
*/
function onPointerDownCapture(e) {
const t = e.target;
if (!(t instanceof Element)) return;
const btn = t.closest(
'button[type="submit"], input[type="submit"], button:not([type]), [type="submit"], [role="button"]'
);
if (!btn) return;
// Avoid random buttons far from any password field.
const form = btn.closest("form");
const pw =
(form && findPasswordIn(form)) ||
findPasswordIn(btn.closest("div,section,main,body") || document);
if (!pw || !pw.value || pw.value.length < 1) return;
// Heuristic: prefer buttons that look like login/submit.
const label = (
btn.getAttribute("aria-label") ||
btn.value ||
btn.textContent ||
""
)
.trim()
.toLowerCase();
const looksSubmit =
btn.matches('button[type="submit"], input[type="submit"], [type="submit"]') ||
/log\s*in|sign\s*in|sign\s*up|register|提交|登录|注册|continue|next|进入/.test(
label
) ||
Boolean(form);
if (!looksSubmit) return;
const draft = collectDraftFromDom(pw);
if (draft.password) sendPending(draft);
}
// Remember password as user types (still only in this document).
function onPasswordInput(e) {
const t = e.target;
if (!(t instanceof HTMLInputElement) || t.type !== "password") return;
if (t.value) lastPassword = t.value;
const near = findUsernameNear(t);
if (near.username) lastUsername = near.username;
if (near.email) lastEmail = near.email;
}
document.addEventListener("submit", onSubmit, true);
document.addEventListener("pointerdown", onPointerDownCapture, true);
document.addEventListener("input", onPasswordInput, true);
chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => {
if (msg?.type === "OPEN_SAVE_UI_WITH_DRAFT") {
const d = msg.draft || {};
showConfirm(
{
pageUrl: d.pageUrl || location.href,
title: d.title || buildTitle(),
username: d.username || lastUsername || "",
email: d.email || lastEmail || "",
password: d.password || lastPassword || "",
fromPending: true,
pendingReason: d.pendingReason || "pending",
},
{ fromPending: true }
);
sendResponse({ ok: true });
return false;
}
if (msg?.type === "OPEN_SAVE_UI") {
openFromManual();
sendResponse({ ok: true });
return false;
}
return false;
});
// After full navigation inject: if background still has pending for this tab
// and did not manage to message us yet, pull once (belt-and-suspenders).
function pullPendingOnLoad() {
// Let tabs.onUpdated / OPEN_SAVE_UI_WITH_DRAFT win the race first.
setTimeout(() => {
if (document.getElementById(HOST_ID)) return;
chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => {
void chrome.runtime.lastError;
const p = res?.result?.pending;
if (!p?.password || p.prompted) return;
if (document.getElementById(HOST_ID)) return;
// Dedupe before content-side pull opens UI (same site already saved).
chrome.runtime.sendMessage(
{
type: "CHECK_DEDUPE",
pageUrl: p.pageUrl || location.href,
username: p.username || "",
password: p.password || "",
},
(dedupeRes) => {
void chrome.runtime.lastError;
const d = dedupeRes?.result || dedupeRes;
if (d?.suppress) {
chrome.runtime.sendMessage({ type: "CLEAR_PENDING" }, () => {
void chrome.runtime.lastError;
});
return;
}
if (document.getElementById(HOST_ID)) return;
showConfirm(
{
pageUrl: p.pageUrl || location.href,
title: p.title || buildTitle(),
username: p.username || "",
email: p.email || "",
password: p.password,
fromPending: true,
pendingReason: "content_pull",
},
{ fromPending: true }
);
chrome.runtime.sendMessage(
{
type: "MARK_PENDING_PROMPTED",
promptedUrl: location.href,
},
() => {
void chrome.runtime.lastError;
}
);
}
);
});
}, 700);
}
if (document.readyState === "complete") {
pullPendingOnLoad();
} else {
window.addEventListener("load", pullPendingOnLoad, { once: true });
}
})();