/** * Persistent config (sync/local) vs session token. */ const SYNC_KEYS = [ "baseUrl", "rootUri", "rootPath", "userId", "email", "connectedAt", /** 上次保存选用的分组路径,扩展确认层默认选中 */ "lastFolderPath", /** * 去重记忆(不跨浏览器配置同步,仅 local): * - savedSites: { [origin]: { [usernameKey]: { credentialId, passwordFp, savedAt, hasSession? } } } * - dismissedSites: { [origin]: { [usernameKey]: dismissedAt } * - neverAskOrigins: { [origin]: true } */ "savedSites", "dismissedSites", "neverAskOrigins", ]; const SESSION_KEYS = ["token", "tokenExpiresAt", "jti"]; /** Normalize username for dedupe keys. */ export function normalizeUsernameKey(username) { return String(username || "") .trim() .toLowerCase(); } /** * Lightweight non-cryptographic fingerprint for password equality checks. * Not a security hash — only used to avoid re-prompting the same login. * @param {string} password */ export function passwordFingerprint(password) { const s = String(password || ""); // FNV-1a 32-bit + length, enough for local dedupe. let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return `fp_${(h >>> 0).toString(16)}_${s.length}`; } /** * @param {string} pageUrl * @returns {string} origin or "" */ export function originFromUrl(pageUrl) { try { return new URL(String(pageUrl || "")).origin; } catch { return ""; } } /** * @returns {Promise<{savedSites:object, dismissedSites:object, neverAskOrigins:object}>} */ export async function loadDedupeState() { const data = await chrome.storage.local.get([ "savedSites", "dismissedSites", "neverAskOrigins", ]); return { savedSites: data.savedSites && typeof data.savedSites === "object" ? data.savedSites : {}, dismissedSites: data.dismissedSites && typeof data.dismissedSites === "object" ? data.dismissedSites : {}, neverAskOrigins: data.neverAskOrigins && typeof data.neverAskOrigins === "object" ? data.neverAskOrigins : {}, }; } /** * Remember a successful save so the same site+user+password does not re-prompt. * @param {{ pageUrl: string, username?: string, password?: string, credentialId?: string, hasSession?: boolean }} info */ export async function rememberSavedSite(info) { const origin = originFromUrl(info.pageUrl); if (!origin) return; const userKey = normalizeUsernameKey(info.username) || "_"; const state = await loadDedupeState(); const byOrigin = { ...(state.savedSites[origin] || {}) }; byOrigin[userKey] = { credentialId: info.credentialId || byOrigin[userKey]?.credentialId || "", passwordFp: passwordFingerprint(info.password || ""), savedAt: new Date().toISOString(), hasSession: Boolean(info.hasSession), }; const savedSites = { ...state.savedSites, [origin]: byOrigin }; // Clear dismiss for this user after explicit save. const dismissed = { ...(state.dismissedSites[origin] || {}) }; delete dismissed[userKey]; const dismissedSites = { ...state.dismissedSites }; if (Object.keys(dismissed).length) dismissedSites[origin] = dismissed; else delete dismissedSites[origin]; await chrome.storage.local.set({ savedSites, dismissedSites }); } /** * User cancelled save prompt for this site+username (suppress for a while). * @param {{ pageUrl: string, username?: string, forever?: boolean }} info */ export async function rememberDismissedSite(info) { const origin = originFromUrl(info.pageUrl); if (!origin) return; if (info.forever) { const state = await loadDedupeState(); await chrome.storage.local.set({ neverAskOrigins: { ...state.neverAskOrigins, [origin]: true }, }); return; } const userKey = normalizeUsernameKey(info.username) || "_"; const state = await loadDedupeState(); const byOrigin = { ...(state.dismissedSites[origin] || {}) }; byOrigin[userKey] = Date.now(); await chrome.storage.local.set({ dismissedSites: { ...state.dismissedSites, [origin]: byOrigin }, }); } /** * Decide whether auto-prompt should be suppressed for this login draft. * @param {{ pageUrl: string, username?: string, password?: string }} draft * @param {{ dismissTtlMs?: number }} [opts] * @returns {Promise<{ suppress: boolean, reason?: string, credentialId?: string, needSessionOnly?: boolean }>} */ export async function shouldSuppressPrompt(draft, opts = {}) { const origin = originFromUrl(draft.pageUrl); if (!origin) return { suppress: false }; const userKey = normalizeUsernameKey(draft.username) || "_"; const state = await loadDedupeState(); if (state.neverAskOrigins[origin]) { return { suppress: true, reason: "never_ask_origin" }; } const dismissTtlMs = opts.dismissTtlMs ?? 14 * 24 * 60 * 60 * 1000; const dismissedAt = state.dismissedSites?.[origin]?.[userKey]; if (typeof dismissedAt === "number" && Date.now() - dismissedAt < dismissTtlMs) { return { suppress: true, reason: "recently_dismissed" }; } const saved = state.savedSites?.[origin]?.[userKey]; if (saved && draft.password) { const fp = passwordFingerprint(draft.password); if (saved.passwordFp === fp) { // Same account+password already saved: do not re-ask for credential. // If session was never captured, allow a silent session-only update path. return { suppress: true, reason: "already_saved_same_password", credentialId: saved.credentialId || "", needSessionOnly: !saved.hasSession, }; } } return { suppress: false }; } /** * @returns {Promise<{baseUrl?:string, rootUri?:string, userId?:string, email?:string, connectedAt?:string, lastFolderPath?:string}>} */ export async function loadPersistent() { return chrome.storage.local.get(SYNC_KEYS); } /** * @param {object} data */ export async function savePersistent(data) { /** @type {Record} */ const patch = {}; for (const k of SYNC_KEYS) { if (data[k] !== undefined) patch[k] = data[k]; } await chrome.storage.local.set(patch); } /** * @returns {Promise<{token?:string, tokenExpiresAt?:string, jti?:string}>} */ export async function loadSession() { // Prefer session storage (cleared on browser restart) when available. if (chrome.storage.session) { return chrome.storage.session.get(SESSION_KEYS); } return chrome.storage.local.get(SESSION_KEYS); } /** * @param {object} data */ export async function saveSession(data) { /** @type {Record} */ const patch = {}; for (const k of SESSION_KEYS) { if (data[k] !== undefined) patch[k] = data[k]; } if (chrome.storage.session) { await chrome.storage.session.set(patch); } else { await chrome.storage.local.set(patch); } } export async function clearSession() { if (chrome.storage.session) { await chrome.storage.session.remove(SESSION_KEYS); } await chrome.storage.local.remove(SESSION_KEYS); } /** * Full runtime config for API calls. * @returns {Promise<{baseUrl:string, rootUri:string, token?:string, userId?:string, email?:string, connected:boolean}>} */ export async function loadConfig() { const [p, s] = await Promise.all([loadPersistent(), loadSession()]); const baseUrl = (p.baseUrl || "").trim(); const rootUri = (p.rootUri || "").trim(); const token = (s.token || "").trim(); const connected = Boolean(baseUrl && rootUri && token); return { baseUrl, rootUri, token: token || undefined, userId: p.userId, email: p.email, connected, tokenExpiresAt: s.tokenExpiresAt, jti: s.jti, lastFolderPath: p.lastFolderPath || "", }; } /** * 规范化逻辑分组路径(与 vault store normalize_folder_path 对齐)。 * @param {string} [raw] * @returns {string} */ export function normalizeFolderPath(raw) { const s = String(raw || "").trim(); if (!s) return ""; const parts = s .split(/[/\\]+/) .map((p) => p.trim()) .filter((p) => p && p !== "." && p !== ".."); return parts.join("/"); } /** * 记住用户上次选用的分组。 * @param {string} folderPath */ export async function saveLastFolderPath(folderPath) { const fp = normalizeFolderPath(folderPath); await savePersistent({ lastFolderPath: fp }); } /** * @param {boolean} connected * @param {string} [label] */ export async function setBadge(connected, label) { try { if (connected) { await chrome.action.setBadgeText({ text: "ON" }); await chrome.action.setBadgeBackgroundColor({ color: "#0a7" }); if (label) { await chrome.action.setTitle({ title: `MNote Vault · ${label}` }); } } else { await chrome.action.setBadgeText({ text: "—" }); await chrome.action.setBadgeBackgroundColor({ color: "#888" }); await chrome.action.setTitle({ title: "MNote Vault · 未连接" }); } } catch { // ignore in non-extension env } }