From 470af371a3ab3e4c04e0324b386f1e502bfb5cb4 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 May 2026 20:26:32 -0400 Subject: [PATCH] 05/02/2026 updated code for security 5 --- app/__init__.py | 28 + app/config.py | 21 +- app/static/js/content.js | 977 ----------------------------------- extension/content/content.js | 972 +++++++++++++++++++++------------- scripts/backup_db.sh | 9 +- 5 files changed, 670 insertions(+), 1337 deletions(-) delete mode 100644 app/static/js/content.js diff --git a/app/__init__.py b/app/__init__.py index af76c2c..e91bed0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -201,4 +201,32 @@ def create_app(config_name: str = 'development') -> Flask: 'in your production .env to enforce global rate limits.' ) + # ── Critical config validation — all environments ────────────────────────── + # TOTP_ENCRYPTION_KEY is required whenever MFA is in use. Validate it at + # startup so a misconfiguration produces a clear error immediately rather + # than a cryptic RuntimeError inside a request handler hours later. + import logging as _startup_log + _slog = _startup_log.getLogger(__name__) + totp_key = app.config.get('TOTP_ENCRYPTION_KEY', '') + if not totp_key: + _slog.warning( + '[PassKeeper] TOTP_ENCRYPTION_KEY is not set. MFA setup and verification ' + 'will fail. Generate a key with: ' + 'python -c "import secrets; print(secrets.token_hex(32))" ' + 'and add it to your .env file.' + ) + elif len(totp_key) != 64: + _slog.error( + '[PassKeeper] TOTP_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes). ' + f'Current value has {len(totp_key)} characters. MFA will not function correctly.' + ) + else: + try: + bytes.fromhex(totp_key) + except ValueError: + _slog.error( + '[PassKeeper] TOTP_ENCRYPTION_KEY contains non-hex characters. ' + 'MFA will not function correctly.' + ) + return app \ No newline at end of file diff --git a/app/config.py b/app/config.py index 01369a2..83e84d6 100644 --- a/app/config.py +++ b/app/config.py @@ -22,6 +22,19 @@ class BaseConfig: ) SQLALCHEMY_TRACK_MODIFICATIONS = False + # MySQL closes idle connections after wait_timeout (default 8 hours). + # pool_recycle ensures SQLAlchemy replaces connections before that deadline. + # pool_pre_ping sends a cheap SELECT 1 before each checkout so stale + # connections are detected and recycled rather than causing "MySQL has gone + # away" errors on the first request after a long idle period. + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_recycle': 3600, # recycle connections after 1 hour + 'pool_pre_ping': True, # test each connection before use + 'pool_timeout': 30, # raise after 30 s if no connection available + 'pool_size': 10, # base pool size per worker + 'max_overflow': 5, # allow up to 5 extra connections under load + } + WTF_CSRF_ENABLED = True WTF_CSRF_TIME_LIMIT = 3600 @@ -46,6 +59,12 @@ class BaseConfig: # Set via .env: STATIC_VERSION=20260418 STATIC_VERSION = os.environ.get('STATIC_VERSION', '1') + # Session cookie defaults — applied in all environments. + # SECURE is intentionally left out of BaseConfig so dev HTTP still works. + # See ProductionConfig below for the full hardened set. + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + class DevelopmentConfig(BaseConfig): DEBUG = True @@ -55,7 +74,7 @@ class DevelopmentConfig(BaseConfig): class ProductionConfig(BaseConfig): DEBUG = False RATELIMIT_ENABLED = True - # Force HTTPS in production + # Force HTTPS in production — marks cookie Secure so it is never sent over HTTP. SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' diff --git a/app/static/js/content.js b/app/static/js/content.js deleted file mode 100644 index 1786b93..0000000 --- a/app/static/js/content.js +++ /dev/null @@ -1,977 +0,0 @@ -/** - * extension/content/content.js — PassKeeper content script. - * - * 1. Detects login forms → notifies background (badge count). - * 2. Injects a PassKeeper icon button OUTSIDE the DOM (position:fixed, tracked - * to the field via scroll/resize) into username AND password fields. - * This avoids breaking site layouts (flex/grid parents, React-controlled inputs). - * 3. Clicking the icon OR focusing a decorated field shows a suggestion dropdown. - * 4. "More options…" shows a second panel with vault/generator actions. - * 5. Listens for DO_AUTOFILL from the popup → fills fields. - * 6. Watches form submissions → shows save-credentials banner. - */ -(() => { - 'use strict'; - - const PK_ATTR = 'data-pk-decorated'; - const PK_BTN_CLASS = '__pk_btn__'; - const PK_DROPDOWN_ID = '__pk_dropdown__'; - const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault'; - - let _bannerEl = null; - let _hasNotifiedForm = false; - let _formObserver = null; - let _matchingItems = []; - // Map from field element → its fixed-position icon button element - const _fieldBtnMap = new WeakMap(); - - // ── Helpers ────────────────────────────────────────────────────────────────── - - function escHtml(str) { - return String(str ?? '').replace(/&/g, '&').replace(//g, '>'); - } - - /** - * More robust visibility check than offsetParent (which fails for - * position:fixed elements and some modern layouts). - */ - function isVisible(el) { - if (!el || !el.getBoundingClientRect) return false; - if (el.disabled) return false; - const rect = el.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; - return true; - } - - /** - * Returns a debounced version of `fn` that waits `ms` milliseconds after - * the last call before firing. Used to avoid re-rendering the dropdown on - * every keystroke. - */ - function _debounce(fn, ms) { - var timer; - return function () { - var args = arguments; - var ctx = this; - clearTimeout(timer); - timer = setTimeout(function () { fn.apply(ctx, args); }, ms); - }; - } - - function visiblePasswordFields() { - return Array.from(document.querySelectorAll('input[type="password"]')) - .filter(el => isVisible(el) && !el.disabled); - } - - /** - * Returns true only if the input field carries signals suggesting it - * collects a credential (username / email / phone) — not a generic - * text field such as a search box, full-name field, or address field. - * - * Scoring precedence: - * 1. autocomplete="username"|"email"|"tel" → definite YES - * 2. Non-credential autocomplete value → definite NO - * 3. name / id / placeholder / aria-label contain a credential keyword → YES - * 4. Otherwise → NO (do not decorate) - */ - function _isLikelyUsernameField(el) { - const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; - const ac = (el.getAttribute('autocomplete') || '').toLowerCase().trim(); - - // Strongest positive signal. - if (['username', 'email', 'tel'].includes(ac)) return true; - - // Definite negative signals (Chrome's autocomplete token set). - const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; - if (ac && NON_CRED_AC.test(ac)) return false; - - // Check name, id, placeholder, and aria-label for credential keywords. - const attrs = [ - el.getAttribute('name') || '', - el.getAttribute('id') || '', - el.getAttribute('placeholder') || '', - el.getAttribute('aria-label') || '', - ].join(' '); - - return CRED_HINTS.test(attrs); - } - - function findUsernameField(pwField) { - // Helper: accept email/tel inputs unconditionally; text inputs only when - // they look like a genuine credential field. - function isCredentialType(el) { - if (el.type === 'email' || el.type === 'tel') return true; - if (el.type === 'text') return _isLikelyUsernameField(el); - return false; - } - - // 1. Walk backwards through all inputs in DOM order. - const all = Array.from(document.querySelectorAll('input')); - const idx = all.indexOf(pwField); - for (let i = idx - 1; i >= 0; i--) { - const el = all[i]; - if (!isVisible(el) || el.disabled) continue; - if (isCredentialType(el)) return el; - } - - // 2. Fallback: search within the same form / ancestor container. - // Prefer email inputs first, then scored text/tel inputs. - const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement; - if (container) { - const emailCandidate = container.querySelector('input[type="email"]:not([disabled])'); - if (emailCandidate && isVisible(emailCandidate)) return emailCandidate; - - const textTelInputs = Array.from( - container.querySelectorAll('input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])') - ); - const scored = textTelInputs.filter(el => isVisible(el) && _isLikelyUsernameField(el)); - if (scored.length) return scored[0]; - } - - return null; - } - - // ── Framework-compatible fill ───────────────────────────────────────────────── - - function fillField(el, value) { - const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - if (nativeSet) nativeSet.call(el, value); - else el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - } - - function doAutofill(username, password) { - const pwFields = visiblePasswordFields(); - if (!pwFields.length) return; - const pwField = pwFields[0]; - const usernameField = findUsernameField(pwField); - if (usernameField && username) fillField(usernameField, username); - if (password) fillField(pwField, password); - [usernameField, pwField].filter(Boolean).forEach(el => { - el.style.outline = '2px solid #c0392b'; - setTimeout(() => { el.style.outline = ''; }, 1500); - }); - } - - // ── Icon button (fixed-position, outside the DOM tree of the field) ─────────── - - /** - * Position the icon button over the right edge of `field` using fixed coords. - * This never touches the field's parent, so it can't break any layout. - */ - function positionBtn(btn, field) { - const rect = field.getBoundingClientRect(); - if (rect.width === 0) { btn.style.display = 'none'; return; } - btn.style.display = 'flex'; - btn.style.top = (rect.top + rect.height / 2 - 13) + 'px'; - btn.style.left = (rect.right - 30) + 'px'; - } - - // createIconBtn is defined in the Field decoration section below. - - // ── Suggestion dropdown ─────────────────────────────────────────────────────── - - function removeDropdown() { - const el = document.getElementById(PK_DROPDOWN_ID); - if (el) el.remove(); - } - - /** - * Build the dropdown anchored below `anchorField`. - * Uses _matchingItems which is kept fresh via storage.onChanged listener. - * `panel` is either 'credentials' (main list) or 'more' (options menu). - */ - async function showDropdown(anchorField, pwField, filterText, panel) { - removeDropdown(); - - // Use module-level _matchingItems (kept fresh by storage.onChanged). - // If still empty, try a direct storage read as last resort. - var freshItems = _matchingItems; - if (!freshItems.length) { - try { - var result = await chrome.storage.session.get('vault_items_cs'); - var all = (result && result.vault_items_cs) || []; - freshItems = _filterForHost(all); - if (freshItems.length) _matchingItems = freshItems; - } catch (e) { } - } - - const rect = anchorField.getBoundingClientRect(); - const dropWidth = Math.max(260, rect.width); - - const dropdown = document.createElement('div'); - dropdown.id = PK_DROPDOWN_ID; - Object.assign(dropdown.style, { - position: 'fixed', - top: (rect.bottom + 4) + 'px', - left: rect.left + 'px', - width: dropWidth + 'px', - background: '#fff', - border: '1px solid #dadce0', - borderRadius: '10px', - boxShadow: '0 6px 24px rgba(0,0,0,0.18)', - zIndex: '2147483647', - fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", - fontSize: '13px', - overflow: 'hidden', - }); - - if (panel === 'more') { - buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText); - } else { - buildCredentialsPanel(dropdown, anchorField, pwField, freshItems, filterText); - } - - document.body.appendChild(dropdown); - - // Reposition on scroll/resize so it stays under the field. - function reposition() { - const r = anchorField.getBoundingClientRect(); - dropdown.style.top = (r.bottom + 4) + 'px'; - dropdown.style.left = r.left + 'px'; - } - window.addEventListener('scroll', reposition, { passive: true, capture: true }); - window.addEventListener('resize', reposition, { passive: true }); - - // Close on outside mousedown or keyboard navigation. - function onOutside(e) { - const btn = _fieldBtnMap.get(anchorField); - if (dropdown.contains(e.target) || e.target === anchorField || (btn && btn.contains(e.target))) return; - removeDropdown(); - document.removeEventListener('mousedown', onOutside, true); - document.removeEventListener('keydown', onKeydown, true); - } - - // Keyboard navigation: Arrow keys move focus between rows; Enter selects; Escape closes. - function onKeydown(e) { - if (e.key === 'Escape') { - removeDropdown(); - document.removeEventListener('mousedown', onOutside, true); - document.removeEventListener('keydown', onKeydown, true); - return; - } - if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return; - - // Only navigate credential rows (divs with data-pk-row attribute). - var rows = Array.from(dropdown.querySelectorAll('[data-pk-row]')); - if (!rows.length) return; - - e.preventDefault(); // prevent the field from scrolling the page - - if (e.key === 'Enter') { - var focused = dropdown.querySelector('[data-pk-row].pk-row-focused'); - if (focused && focused._pkFill) focused._pkFill(); - return; - } - - var currentIdx = rows.findIndex(function (r) { return r.classList.contains('pk-row-focused'); }); - var nextIdx; - if (e.key === 'ArrowDown') { - nextIdx = currentIdx < rows.length - 1 ? currentIdx + 1 : 0; - } else { - nextIdx = currentIdx > 0 ? currentIdx - 1 : rows.length - 1; - } - - rows.forEach(function (r) { - r.classList.remove('pk-row-focused'); - r.style.background = ''; - }); - rows[nextIdx].classList.add('pk-row-focused'); - rows[nextIdx].style.background = '#e8f0fe'; - rows[nextIdx].scrollIntoView({ block: 'nearest' }); - } - - setTimeout(function () { - document.addEventListener('mousedown', onOutside, true); - document.addEventListener('keydown', onKeydown, true); - }, 0); - } - - // ── Credentials panel (main list) ──────────────────────────────────────────── - - function buildCredentialsPanel(dropdown, anchorField, pwField, items, filterText) { - const q = (filterText || '').trim().toLowerCase(); - const filtered = q - ? items.filter(function (item) { - return ((item.plain && item.plain.username) || '').toLowerCase().includes(q) || - item.name.toLowerCase().includes(q); - }) - : items; - - const usernameField = anchorField.type === 'password' ? findUsernameField(anchorField) : anchorField; - - if (filtered.length === 0) { - // No saved passwords — show a minimal "no items" row + More options. - const empty = document.createElement('div'); - Object.assign(empty.style, { - padding: '12px 14px', - color: '#5f6368', - fontSize: '12px', - }); - empty.textContent = q ? 'No matches found.' : 'No saved passwords for this site.'; - dropdown.appendChild(empty); - } else { - filtered.forEach(function (item) { - const row = document.createElement('div'); - row.setAttribute('data-pk-row', '1'); // enables keyboard navigation - Object.assign(row.style, { - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '10px 14px', - cursor: 'pointer', - transition: 'background 0.1s', - }); - row.onmouseenter = function () { - if (!row.classList.contains('pk-row-focused')) row.style.background = '#f1f3f4'; - }; - row.onmouseleave = function () { - if (!row.classList.contains('pk-row-focused')) row.style.background = ''; - }; - - // Derive display hostname. - var siteHost = item.name; - if (item.plain && item.plain.url) { - try { siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch (e) { } - } - - var username = escHtml((item.plain && item.plain.username) || ''); - var site = escHtml(siteHost); - - // Lock icon avatar — filled dark circle like the screenshot. - var avatar = document.createElement('div'); - Object.assign(avatar.style, { - width: '34px', - height: '34px', - borderRadius: '50%', - background: '#1a1a2e', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: '0', - }); - avatar.innerHTML = - '' + - '' + - '' + - ''; - - // Text. - var text = document.createElement('div'); - text.style.cssText = 'flex:1;min-width:0;'; - text.innerHTML = - '
' + site + '
' + - (username ? '
' + username + '
' : ''); - - // Edit pencil. - var editBtn = document.createElement('button'); - Object.assign(editBtn.style, { - background: 'none', - border: 'none', - cursor: 'pointer', - padding: '5px', - color: '#1a73e8', - display: 'flex', - alignItems: 'center', - flexShrink: '0', - borderRadius: '4px', - }); - editBtn.title = 'Edit in PassKeeper'; - editBtn.innerHTML = - '' + - '' + - '' + - ''; - editBtn.addEventListener('mousedown', function (e) { - e.preventDefault(); - e.stopPropagation(); - chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); - removeDropdown(); - }); - - row.appendChild(avatar); - row.appendChild(text); - row.appendChild(editBtn); - - // Shared fill action — used by both mousedown and keyboard Enter. - function doFill() { - if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); - if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); - - // ✓ Filled flash: replace row content briefly before closing. - row.innerHTML = - '
' + - '' + - '' + - 'Filled
'; - row.style.background = '#f0fdf4'; - - setTimeout(function () { - removeDropdown(); - if (pwField && anchorField !== pwField) pwField.focus(); - }, 600); - } - - row.addEventListener('mousedown', function (e) { - if (e.target === editBtn || editBtn.contains(e.target)) return; - e.preventDefault(); - doFill(); - }); - - // Expose doFill for the keyboard Enter handler via a custom property. - row._pkFill = doFill; - - dropdown.appendChild(row); - }); - } - - // Divider + "More options…" footer — always shown. - var divider = document.createElement('div'); - divider.style.cssText = 'height:1px;background:#e8eaed;'; - dropdown.appendChild(divider); - - var more = document.createElement('div'); - Object.assign(more.style, { - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '10px 14px', - cursor: 'pointer', - color: '#202124', - fontSize: '13px', - transition: 'background 0.1s', - }); - more.onmouseenter = function () { more.style.background = '#f1f3f4'; }; - more.onmouseleave = function () { more.style.background = ''; }; - more.innerHTML = - '' + - '' + - '' + - '' + - '' + - 'More options\u2026'; - more.addEventListener('mousedown', function (e) { - e.preventDefault(); - showDropdown(anchorField, pwField, filterText, 'more'); - }); - dropdown.appendChild(more); - } - - // ── More options panel ──────────────────────────────────────────────────────── - - function buildMorePanel(dropdown, anchorField, pwField, items, filterText) { - // Back header. - var backRow = document.createElement('div'); - Object.assign(backRow.style, { - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '10px 14px', - cursor: 'pointer', - color: '#1a73e8', - fontSize: '13px', - fontWeight: '600', - borderBottom: '1px solid #e8eaed', - transition: 'background 0.1s', - }); - backRow.onmouseenter = function () { backRow.style.background = '#f1f3f4'; }; - backRow.onmouseleave = function () { backRow.style.background = ''; }; - backRow.innerHTML = - '' + - '' + - ' Back'; - backRow.addEventListener('mousedown', function (e) { - e.preventDefault(); - showDropdown(anchorField, pwField, filterText, 'credentials'); - }); - dropdown.appendChild(backRow); - - // Menu items matching the screenshot. - var menuItems = [ - { - icon: '', - label: 'Report a problem', - action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, - }, - { - icon: '', - label: 'Generate a password', - chevron: true, - action: function () { chrome.runtime.sendMessage({ type: 'OPEN_GENERATOR' }).catch(function () { }); removeDropdown(); }, - }, - { - icon: '', - label: 'Open my vault', - action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, - }, - ]; - - menuItems.forEach(function (item) { - var row = document.createElement('div'); - Object.assign(row.style, { - display: 'flex', - alignItems: 'center', - gap: '12px', - padding: '11px 14px', - cursor: 'pointer', - color: '#202124', - fontSize: '13px', - transition: 'background 0.1s', - borderBottom: '1px solid #f3f4f6', - }); - row.onmouseenter = function () { row.style.background = '#f1f3f4'; }; - row.onmouseleave = function () { row.style.background = ''; }; - - var iconWrap = document.createElement('div'); - iconWrap.style.cssText = 'width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;'; - iconWrap.innerHTML = '' + item.icon + ''; - - var label = document.createElement('span'); - label.style.cssText = 'flex:1;'; - label.textContent = item.label; - - row.appendChild(iconWrap); - row.appendChild(label); - - if (item.chevron) { - var chev = document.createElement('div'); - chev.innerHTML = - '' + - '' + - ''; - row.appendChild(chev); - } else { - var extIcon = document.createElement('div'); - extIcon.innerHTML = - '' + - '' + - '' + - ''; - row.appendChild(extIcon); - } - - row.addEventListener('mousedown', function (e) { - e.preventDefault(); - item.action(); - }); - - dropdown.appendChild(row); - }); - } - - // ── Field decoration ────────────────────────────────────────────────────────── - - // Map from field element → AbortController so we can cancel its listeners on re-decoration. - const _fieldAbortMap = new WeakMap(); - - function decorateField(field, pwField) { - if (field.getAttribute(PK_ATTR)) return; - field.setAttribute(PK_ATTR, '1'); - - // Cancel any previous listeners on this field. - const prevAC = _fieldAbortMap.get(field); - if (prevAC) prevAC.abort(); - const ac = new AbortController(); - _fieldAbortMap.set(field, ac); - const sig = ac.signal; - - createIconBtn(field, pwField, sig); - } - - function createIconBtn(field, pwField, abortSignal) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = PK_BTN_CLASS; - btn.title = 'PassKeeper autofill'; - btn.setAttribute('aria-label', 'Autofill with PassKeeper'); - btn.style.cssText = [ - 'position:fixed', - 'width:26px', - 'height:26px', - 'background:#c0392b', - 'border:none', - 'border-radius:5px', - 'cursor:pointer', - 'display:flex', - 'align-items:center', - 'justify-content:center', - 'z-index:2147483646', - 'padding:0', - 'box-shadow:0 1px 4px rgba(0,0,0,0.3)', - 'transition:background 0.15s', - ].join(';'); - - btn.innerHTML = - '' + - '' + - '' + - '' + - ''; - - btn.addEventListener('mouseenter', function () { btn.style.background = '#a93226'; }); - btn.addEventListener('mouseleave', function () { btn.style.background = '#c0392b'; }); - - positionBtn(btn, field); - document.body.appendChild(btn); - _fieldBtnMap.set(field, btn); - - // Remove the button when the AbortController fires (re-decoration). - abortSignal.addEventListener('abort', function () { - btn.remove(); - _fieldBtnMap.delete(field); - }); - - // Keep button tracked as page scrolls/resizes. - function reposition() { if (document.body.contains(btn)) positionBtn(btn, field); } - window.addEventListener('scroll', reposition, { passive: true, signal: abortSignal }); - window.addEventListener('resize', reposition, { passive: true, signal: abortSignal }); - - // ── All event handlers read _matchingItems at call time, never from closure ── - - // Show dropdown on focus — reads vault_items fresh from storage each time. - field.addEventListener('focus', function () { - showDropdown(field, pwField, field.value, 'credentials'); - }, { signal: abortSignal }); - - // Re-filter as user types — debounced to avoid rebuilding the dropdown - // on every single keystroke (noticeable on large vaults or slow machines). - var _debouncedShow = _debounce(function () { - showDropdown(field, pwField, field.value, 'credentials'); - }, 150); - field.addEventListener('input', _debouncedShow, { signal: abortSignal }); - - // Dim button when field loses focus and no dropdown is open. - field.addEventListener('blur', function () { - setTimeout(function () { - if (!document.getElementById(PK_DROPDOWN_ID)) btn.style.opacity = '0.4'; - }, 150); - }, { signal: abortSignal }); - - field.addEventListener('focus', function () { - btn.style.opacity = '1'; - }, { signal: abortSignal }); - - // Icon click: toggle dropdown. - btn.addEventListener('mousedown', function (e) { - e.preventDefault(); - e.stopPropagation(); - if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } - else { showDropdown(field, pwField, field.value, 'credentials'); } - }); - - return btn; - } - - /** - * Decorate all visible password (and paired username) fields with the PassKeeper - * icon button. - * - * @param {Array|null} knownItems When the caller already holds the correct - * filtered item list (e.g. from a storage.onChanged newValue or a VAULT_UPDATED - * message payload), pass it here to skip the redundant storage read. - * Pass null/undefined to let this function read storage itself. - */ - async function decorateFields(knownItems) { - if (knownItems != null) { - // Caller supplied items — trust them and skip the storage round-trip. - _matchingItems = knownItems; - console.log('[PassKeeper] decorateFields (inline): host=' + location.hostname.replace(/^www\./, '') + - ', matched=' + _matchingItems.length); - } else { - // Read from chrome.storage.session — memory-only, cleared on browser close. - // Decrypted vault data must never be written to persistent (local) storage. - var all = []; - try { - var result = await chrome.storage.session.get('vault_items_cs'); - all = (result && result.vault_items_cs) || []; - } catch (e) { } - _matchingItems = _filterForHost(all); - console.log('[PassKeeper] decorateFields (storage): host=' + location.hostname.replace(/^www\./, '') + - ', matched=' + _matchingItems.length + ' of ' + all.length + ' items'); - } - - // Decorate every visible password field and its paired username field. - visiblePasswordFields().forEach(function (pwField) { - var usernameField = findUsernameField(pwField); - if (usernameField) decorateField(usernameField, pwField); - decorateField(pwField, pwField); - }); - } - - // ── Vault item helpers ──────────────────────────────────────────────────────── - - /** - * Normalise a stored URL string so it is always parseable by `new URL()`. - * Handles bare domains ("github.com"), protocol-relative ("//github.com"), - * and fully-formed URLs ("https://github.com") identically. - */ - function _normaliseUrl(raw) { - if (!raw) return null; - var s = raw.trim(); - if (/^https?:\/\//i.test(s)) return s; // already has a scheme - if (s.startsWith('//')) return 'https:' + s; // protocol-relative - return 'https://' + s; // bare domain or path - } - - function _filterForHost(items) { - var host = location.hostname.replace(/^www\./, ''); - return (items || []).filter(function (item) { - if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false; - try { - var normalised = _normaliseUrl(item.plain.url); - if (!normalised) return false; - var h = new URL(normalised).hostname.replace(/^www\./, ''); - // Match exact domain or any subdomain relationship. - return h === host || h.endsWith('.' + host) || host.endsWith('.' + h); - } catch (e) { - console.warn('[PassKeeper] _filterForHost: could not parse URL:', item.plain.url, e.message); - return false; - } - }); - } - - // ── Form detection ──────────────────────────────────────────────────────────── - - function notifyFormDetected() { - if (_hasNotifiedForm) return; - if (!visiblePasswordFields().length) return; - _hasNotifiedForm = true; - chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(function () { }); - } - - // ── Duplicate detection ─────────────────────────────────────────────────────── - - async function classifyCredentials(username, password) { - var all = []; - try { - var result = await chrome.storage.session.get('vault_items_cs'); - all = (result && result.vault_items_cs) || []; - } catch (e) { return 'new'; } - if (!all.length) return 'new'; - - var siteItems = _filterForHost(all); - if (!siteItems.length) return 'new'; - var exactMatch = siteItems.some(function (item) { - return item.plain && item.plain.username === username && item.plain.password === password; - }); - return exactMatch ? 'same' : 'updated'; - } - - // ── Save blocklist ──────────────────────────────────────────────────────────── - - const BLOCKLIST_KEY = 'save_blocklist'; - - async function isBlocked(hostname) { - try { - var result = await chrome.storage.local.get(BLOCKLIST_KEY); - var list = (result && result[BLOCKLIST_KEY]) || []; - return list.indexOf(hostname) !== -1; - } catch (e) { return false; } - } - - async function addToBlocklist(hostname) { - try { - var result = await chrome.storage.local.get(BLOCKLIST_KEY); - var list = (result && result[BLOCKLIST_KEY]) || []; - if (list.indexOf(hostname) === -1) { - list.push(hostname); - await chrome.storage.local.set({ [BLOCKLIST_KEY]: list }); - console.log('[PassKeeper] Added to save blocklist:', hostname); - } - } catch (e) { } - } - - // ── Auto-save banner ────────────────────────────────────────────────────────── - - function showSaveBanner(username, password, credentialState) { - if (_bannerEl) _bannerEl.remove(); - - var banner = document.createElement('div'); - banner.id = '__pk_save_banner__'; - Object.assign(banner.style, { - position: 'fixed', - top: '12px', - right: '12px', - zIndex: '2147483647', - background: '#ffffff', - border: '1px solid #e2e8f0', - borderRadius: '10px', - boxShadow: '0 8px 30px rgba(0,0,0,0.15)', - padding: '14px 16px 12px', - fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", - fontSize: '13px', - color: '#1a1a2e', - maxWidth: '300px', - minWidth: '240px', - }); - - var site = escHtml(location.hostname); - var user = escHtml(username); - // Default site name: prefer page title (trimmed), fall back to hostname. - var defaultSiteName = (document.title || '').trim().slice(0, 60) || location.hostname; - var title = credentialState === 'updated' ? 'Update in PassKeeper?' : 'Save to PassKeeper?'; - - banner.innerHTML = - '
' + - '' + - '' + escHtml(title) + '' + - '' + - '
' + - '
' + - '' + - '' + - '
' + - '

' + - '' + user + ' on ' + site + '' + - '

' + - '
' + - '' + - '' + - '
' + - ''; - - document.body.appendChild(banner); - _bannerEl = banner; - - var dismiss = function () { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; - banner.querySelector('#__pk_close__').addEventListener('click', dismiss); - banner.querySelector('#__pk_skip__').addEventListener('click', dismiss); - banner.querySelector('#__pk_save__').addEventListener('click', function () { - var siteName = (banner.querySelector('#__pk_site_name__').value || '').trim() || location.hostname; - console.log('[PassKeeper] User chose to save credentials for', location.hostname, '— site name:', siteName); - chrome.runtime.sendMessage({ - type: 'SAVE_CREDENTIALS', - data: { url: location.href, siteName: siteName, username: username, password: password }, - }).catch(function () { }); - dismiss(); - }); - banner.querySelector('#__pk_never__').addEventListener('click', function () { - addToBlocklist(location.hostname); - dismiss(); - }); - } - - // ── Form submission watch ───────────────────────────────────────────────────── - - function watchSubmissions() { - document.addEventListener('submit', async function (e) { - var form = e.target; - var pwField = form.querySelector('input[type="password"]:not([disabled])'); - if (!pwField || !pwField.value) return; - - var userField = findUsernameField(pwField) - || form.querySelector('input[type="email"]:not([disabled])') - || form.querySelector('input[type="text"]:not([disabled])'); - - var username = (userField && userField.value && userField.value.trim()) || ''; - var password = pwField.value; - if (!username || !password) return; - - removeDropdown(); - - // Check blocklist before doing anything else. - if (await isBlocked(location.hostname)) { - console.log('[PassKeeper] Site is blocklisted, skipping save banner:', location.hostname); - return; - } - - var credentialState = await classifyCredentials(username, password); - console.log('[PassKeeper] Credential state for', location.hostname, '\u2192', credentialState); - if (credentialState === 'same') return; - - setTimeout(function () { showSaveBanner(username, password, credentialState); }, 500); - }, true); - } - - // ── Message listener ────────────────────────────────────────────────────────── - - chrome.runtime.onMessage.addListener(function (msg, _sender, sendResponse) { - if (msg.type === 'DO_AUTOFILL') { - doAutofill(msg.username, msg.password); - sendResponse({ ok: true }); - } - if (msg.type === 'VAULT_UPDATED') { - // Items may arrive in the message payload (best-effort), but the source - // of truth is now chrome.storage.session which was already written by the popup. - var allItems = msg.vault_items || []; - var matched = allItems.length ? _filterForHost(allItems) : null; - if (matched !== null) { - _matchingItems = matched; - console.log('[PassKeeper] VAULT_UPDATED (message): matched=' + _matchingItems.length + ' of ' + allItems.length); - } - // Re-decorate. Pass matched items so decorateFields skips the storage read - // when the message payload was non-empty; fall back to storage otherwise. - document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { - var ac = _fieldAbortMap.get(el); - if (ac) ac.abort(); - el.removeAttribute(PK_ATTR); - }); - document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); - removeDropdown(); - decorateFields(matched); - } - return false; - }); - - // ── Init ────────────────────────────────────────────────────────────────────── - - function init() { - notifyFormDetected(); - decorateFields(); - watchSubmissions(); - - // React instantly when the popup writes fresh vault data to local storage. - // This fires in the same tick as the write — no message delivery required. - chrome.storage.onChanged.addListener(function (changes, area) { - if (area === 'session' && changes.vault_items_cs) { - var allItems = (changes.vault_items_cs.newValue) || []; - var matched = _filterForHost(allItems); - _matchingItems = matched; - console.log('[PassKeeper] storage.onChanged: matched=' + matched.length + ' of ' + allItems.length + ' items'); - // Re-decorate, passing the already-filtered list to avoid a redundant storage read. - document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { - var ac = _fieldAbortMap.get(el); - if (ac) ac.abort(); - el.removeAttribute(PK_ATTR); - }); - document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); - removeDropdown(); - decorateFields(matched); - } - }); - - _formObserver = new MutationObserver(function (mutations) { - // Ignore mutations caused by the extension's own injected elements - // (dropdown, icon buttons, save banner) to prevent re-decoration loops - // on SPAs that react to every DOM change. - var ownMutation = mutations.every(function (m) { - return Array.from(m.addedNodes).concat(Array.from(m.removedNodes)).every(function (node) { - if (!node || node.nodeType !== 1) return true; - var cls = (node.className || ''); - var id = (node.id || ''); - return cls.indexOf('__pk') !== -1 || - id.indexOf('__pk') !== -1 || - node.querySelector && ( - node.querySelector('.' + PK_BTN_CLASS) || - node.querySelector('#' + PK_DROPDOWN_ID) - ); - }); - }); - if (ownMutation) return; - _hasNotifiedForm = false; - notifyFormDetected(); - decorateFields(); - }); - _formObserver.observe(document.body, { childList: true, subtree: true }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); \ No newline at end of file diff --git a/extension/content/content.js b/extension/content/content.js index c25aac6..ee9a110 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -11,12 +11,12 @@ * 6. Watches form submissions → shows save-credentials banner. */ (() => { - 'use strict'; + "use strict"; - const PK_ATTR = 'data-pk-decorated'; - const PK_BTN_CLASS = '__pk_btn__'; - const PK_DROPDOWN_ID = '__pk_dropdown__'; - const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault'; + const PK_ATTR = "data-pk-decorated"; + const PK_BTN_CLASS = "__pk_btn__"; + const PK_DROPDOWN_ID = "__pk_dropdown__"; + const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; let _bannerEl = null; let _hasNotifiedForm = false; @@ -28,7 +28,10 @@ // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { - return String(str ?? '').replace(/&/g, '&').replace(//g, '>'); + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); } /** @@ -41,7 +44,12 @@ const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false; const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; + if ( + style.display === "none" || + style.visibility === "hidden" || + style.opacity === "0" + ) + return false; return true; } @@ -56,13 +64,16 @@ var args = arguments; var ctx = this; clearTimeout(timer); - timer = setTimeout(function () { fn.apply(ctx, args); }, ms); + timer = setTimeout(function () { + fn.apply(ctx, args); + }, ms); }; } function visiblePasswordFields() { - return Array.from(document.querySelectorAll('input[type="password"]')) - .filter(el => isVisible(el) && !el.disabled); + return Array.from( + document.querySelectorAll('input[type="password"]'), + ).filter((el) => isVisible(el) && !el.disabled); } /** @@ -78,22 +89,23 @@ */ function _isLikelyUsernameField(el) { const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; - const ac = (el.getAttribute('autocomplete') || '').toLowerCase().trim(); + const ac = (el.getAttribute("autocomplete") || "").toLowerCase().trim(); // Strongest positive signal. - if (['username', 'email', 'tel'].includes(ac)) return true; + if (["username", "email", "tel"].includes(ac)) return true; // Definite negative signals (Chrome's autocomplete token set). - const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; + const NON_CRED_AC = + /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; if (ac && NON_CRED_AC.test(ac)) return false; // Check name, id, placeholder, and aria-label for credential keywords. const attrs = [ - el.getAttribute('name') || '', - el.getAttribute('id') || '', - el.getAttribute('placeholder') || '', - el.getAttribute('aria-label') || '', - ].join(' '); + el.getAttribute("name") || "", + el.getAttribute("id") || "", + el.getAttribute("placeholder") || "", + el.getAttribute("aria-label") || "", + ].join(" "); return CRED_HINTS.test(attrs); } @@ -102,13 +114,13 @@ // Helper: accept email/tel inputs unconditionally; text inputs only when // they look like a genuine credential field. function isCredentialType(el) { - if (el.type === 'email' || el.type === 'tel') return true; - if (el.type === 'text') return _isLikelyUsernameField(el); + if (el.type === "email" || el.type === "tel") return true; + if (el.type === "text") return _isLikelyUsernameField(el); return false; } // 1. Walk backwards through all inputs in DOM order. - const all = Array.from(document.querySelectorAll('input')); + const all = Array.from(document.querySelectorAll("input")); const idx = all.indexOf(pwField); for (let i = idx - 1; i >= 0; i--) { const el = all[i]; @@ -118,15 +130,24 @@ // 2. Fallback: search within the same form / ancestor container. // Prefer email inputs first, then scored text/tel inputs. - const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement; + const container = + pwField.closest("form") || + pwField.closest('[role="form"]') || + pwField.parentElement; if (container) { - const emailCandidate = container.querySelector('input[type="email"]:not([disabled])'); + const emailCandidate = container.querySelector( + 'input[type="email"]:not([disabled])', + ); if (emailCandidate && isVisible(emailCandidate)) return emailCandidate; const textTelInputs = Array.from( - container.querySelectorAll('input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])') + container.querySelectorAll( + 'input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])', + ), + ); + const scored = textTelInputs.filter( + (el) => isVisible(el) && _isLikelyUsernameField(el), ); - const scored = textTelInputs.filter(el => isVisible(el) && _isLikelyUsernameField(el)); if (scored.length) return scored[0]; } @@ -136,11 +157,14 @@ // ── Framework-compatible fill ───────────────────────────────────────────────── function fillField(el, value) { - const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + const nativeSet = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; if (nativeSet) nativeSet.call(el, value); else el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); } function doAutofill(username, password) { @@ -150,9 +174,11 @@ const usernameField = findUsernameField(pwField); if (usernameField && username) fillField(usernameField, username); if (password) fillField(pwField, password); - [usernameField, pwField].filter(Boolean).forEach(el => { - el.style.outline = '2px solid #c0392b'; - setTimeout(() => { el.style.outline = ''; }, 1500); + [usernameField, pwField].filter(Boolean).forEach((el) => { + el.style.outline = "2px solid #c0392b"; + setTimeout(() => { + el.style.outline = ""; + }, 1500); }); } @@ -164,10 +190,13 @@ */ function positionBtn(btn, field) { const rect = field.getBoundingClientRect(); - if (rect.width === 0) { btn.style.display = 'none'; return; } - btn.style.display = 'flex'; - btn.style.top = (rect.top + rect.height / 2 - 13) + 'px'; - btn.style.left = (rect.right - 30) + 'px'; + if (rect.width === 0) { + btn.style.display = "none"; + return; + } + btn.style.display = "flex"; + btn.style.top = rect.top + rect.height / 2 - 13 + "px"; + btn.style.left = rect.right - 30 + "px"; } // createIconBtn is defined in the Field decoration section below. @@ -192,37 +221,44 @@ var freshItems = _matchingItems; if (!freshItems.length) { try { - var result = await chrome.storage.session.get('vault_items_cs'); + var result = await chrome.storage.session.get("vault_items_cs"); var all = (result && result.vault_items_cs) || []; freshItems = _filterForHost(all); if (freshItems.length) _matchingItems = freshItems; - } catch (e) { } + } catch (e) {} } const rect = anchorField.getBoundingClientRect(); const dropWidth = Math.max(260, rect.width); - const dropdown = document.createElement('div'); + const dropdown = document.createElement("div"); dropdown.id = PK_DROPDOWN_ID; Object.assign(dropdown.style, { - position: 'fixed', - top: (rect.bottom + 4) + 'px', - left: rect.left + 'px', - width: dropWidth + 'px', - background: '#fff', - border: '1px solid #dadce0', - borderRadius: '10px', - boxShadow: '0 6px 24px rgba(0,0,0,0.18)', - zIndex: '2147483647', - fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", - fontSize: '13px', - overflow: 'hidden', + position: "fixed", + top: rect.bottom + 4 + "px", + left: rect.left + "px", + width: dropWidth + "px", + background: "#fff", + border: "1px solid #dadce0", + borderRadius: "10px", + boxShadow: "0 6px 24px rgba(0,0,0,0.18)", + zIndex: "2147483647", + fontFamily: + "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", + fontSize: "13px", + overflow: "hidden", }); - if (panel === 'more') { + if (panel === "more") { buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText); } else { - buildCredentialsPanel(dropdown, anchorField, pwField, freshItems, filterText); + buildCredentialsPanel( + dropdown, + anchorField, + pwField, + freshItems, + filterText, + ); } document.body.appendChild(dropdown); @@ -230,165 +266,202 @@ // Reposition on scroll/resize so it stays under the field. function reposition() { const r = anchorField.getBoundingClientRect(); - dropdown.style.top = (r.bottom + 4) + 'px'; - dropdown.style.left = r.left + 'px'; + dropdown.style.top = r.bottom + 4 + "px"; + dropdown.style.left = r.left + "px"; } - window.addEventListener('scroll', reposition, { passive: true, capture: true }); - window.addEventListener('resize', reposition, { passive: true }); + window.addEventListener("scroll", reposition, { + passive: true, + capture: true, + }); + window.addEventListener("resize", reposition, { passive: true }); // Close on outside mousedown or keyboard navigation. function onOutside(e) { const btn = _fieldBtnMap.get(anchorField); - if (dropdown.contains(e.target) || e.target === anchorField || (btn && btn.contains(e.target))) return; + if ( + dropdown.contains(e.target) || + e.target === anchorField || + (btn && btn.contains(e.target)) + ) + return; removeDropdown(); - document.removeEventListener('mousedown', onOutside, true); - document.removeEventListener('keydown', onKeydown, true); + document.removeEventListener("mousedown", onOutside, true); + document.removeEventListener("keydown", onKeydown, true); } // Keyboard navigation: Arrow keys move focus between rows; Enter selects; Escape closes. function onKeydown(e) { - if (e.key === 'Escape') { + if (e.key === "Escape") { removeDropdown(); - document.removeEventListener('mousedown', onOutside, true); - document.removeEventListener('keydown', onKeydown, true); + document.removeEventListener("mousedown", onOutside, true); + document.removeEventListener("keydown", onKeydown, true); return; } - if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return; + if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Enter") + return; // Only navigate credential rows (divs with data-pk-row attribute). - var rows = Array.from(dropdown.querySelectorAll('[data-pk-row]')); + var rows = Array.from(dropdown.querySelectorAll("[data-pk-row]")); if (!rows.length) return; e.preventDefault(); // prevent the field from scrolling the page - if (e.key === 'Enter') { - var focused = dropdown.querySelector('[data-pk-row].pk-row-focused'); + if (e.key === "Enter") { + var focused = dropdown.querySelector("[data-pk-row].pk-row-focused"); if (focused && focused._pkFill) focused._pkFill(); return; } - var currentIdx = rows.findIndex(function (r) { return r.classList.contains('pk-row-focused'); }); + var currentIdx = rows.findIndex(function (r) { + return r.classList.contains("pk-row-focused"); + }); var nextIdx; - if (e.key === 'ArrowDown') { + if (e.key === "ArrowDown") { nextIdx = currentIdx < rows.length - 1 ? currentIdx + 1 : 0; } else { nextIdx = currentIdx > 0 ? currentIdx - 1 : rows.length - 1; } rows.forEach(function (r) { - r.classList.remove('pk-row-focused'); - r.style.background = ''; + r.classList.remove("pk-row-focused"); + r.style.background = ""; }); - rows[nextIdx].classList.add('pk-row-focused'); - rows[nextIdx].style.background = '#e8f0fe'; - rows[nextIdx].scrollIntoView({ block: 'nearest' }); + rows[nextIdx].classList.add("pk-row-focused"); + rows[nextIdx].style.background = "#e8f0fe"; + rows[nextIdx].scrollIntoView({ block: "nearest" }); } setTimeout(function () { - document.addEventListener('mousedown', onOutside, true); - document.addEventListener('keydown', onKeydown, true); + document.addEventListener("mousedown", onOutside, true); + document.addEventListener("keydown", onKeydown, true); }, 0); } // ── Credentials panel (main list) ──────────────────────────────────────────── - function buildCredentialsPanel(dropdown, anchorField, pwField, items, filterText) { - const q = (filterText || '').trim().toLowerCase(); + function buildCredentialsPanel( + dropdown, + anchorField, + pwField, + items, + filterText, + ) { + const q = (filterText || "").trim().toLowerCase(); const filtered = q ? items.filter(function (item) { - return ((item.plain && item.plain.username) || '').toLowerCase().includes(q) || - item.name.toLowerCase().includes(q); - }) + return ( + ((item.plain && item.plain.username) || "") + .toLowerCase() + .includes(q) || item.name.toLowerCase().includes(q) + ); + }) : items; - const usernameField = anchorField.type === 'password' ? findUsernameField(anchorField) : anchorField; + const usernameField = + anchorField.type === "password" + ? findUsernameField(anchorField) + : anchorField; if (filtered.length === 0) { // No saved passwords — show a minimal "no items" row + More options. - const empty = document.createElement('div'); + const empty = document.createElement("div"); Object.assign(empty.style, { - padding: '12px 14px', - color: '#5f6368', - fontSize: '12px', + padding: "12px 14px", + color: "#5f6368", + fontSize: "12px", }); - empty.textContent = q ? 'No matches found.' : 'No saved passwords for this site.'; + empty.textContent = q + ? "No matches found." + : "No saved passwords for this site."; dropdown.appendChild(empty); } else { filtered.forEach(function (item) { - const row = document.createElement('div'); - row.setAttribute('data-pk-row', '1'); // enables keyboard navigation + const row = document.createElement("div"); + row.setAttribute("data-pk-row", "1"); // enables keyboard navigation Object.assign(row.style, { - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '10px 14px', - cursor: 'pointer', - transition: 'background 0.1s', + display: "flex", + alignItems: "center", + gap: "10px", + padding: "10px 14px", + cursor: "pointer", + transition: "background 0.1s", }); row.onmouseenter = function () { - if (!row.classList.contains('pk-row-focused')) row.style.background = '#f1f3f4'; + if (!row.classList.contains("pk-row-focused")) + row.style.background = "#f1f3f4"; }; row.onmouseleave = function () { - if (!row.classList.contains('pk-row-focused')) row.style.background = ''; + if (!row.classList.contains("pk-row-focused")) + row.style.background = ""; }; // Derive display hostname. var siteHost = item.name; if (item.plain && item.plain.url) { - try { siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch (e) { } + try { + siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ""); + } catch (e) {} } - var username = escHtml((item.plain && item.plain.username) || ''); + var username = escHtml((item.plain && item.plain.username) || ""); var site = escHtml(siteHost); // Lock icon avatar — filled dark circle like the screenshot. - var avatar = document.createElement('div'); + var avatar = document.createElement("div"); Object.assign(avatar.style, { - width: '34px', - height: '34px', - borderRadius: '50%', - background: '#1a1a2e', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: '0', + width: "34px", + height: "34px", + borderRadius: "50%", + background: "#1a1a2e", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: "0", }); avatar.innerHTML = '' + '' + '' + - ''; + ""; // Text. - var text = document.createElement('div'); - text.style.cssText = 'flex:1;min-width:0;'; + var text = document.createElement("div"); + text.style.cssText = "flex:1;min-width:0;"; text.innerHTML = - '
' + site + '
' + - (username ? '
' + username + '
' : ''); + '
' + + site + + "
" + + (username + ? '
' + + username + + "
" + : ""); // Edit pencil. - var editBtn = document.createElement('button'); + var editBtn = document.createElement("button"); Object.assign(editBtn.style, { - background: 'none', - border: 'none', - cursor: 'pointer', - padding: '5px', - color: '#1a73e8', - display: 'flex', - alignItems: 'center', - flexShrink: '0', - borderRadius: '4px', + background: "none", + border: "none", + cursor: "pointer", + padding: "5px", + color: "#1a73e8", + display: "flex", + alignItems: "center", + flexShrink: "0", + borderRadius: "4px", }); - editBtn.title = 'Edit in PassKeeper'; + editBtn.title = "Edit in PassKeeper"; editBtn.innerHTML = '' + '' + '' + - ''; - editBtn.addEventListener('mousedown', function (e) { + ""; + editBtn.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); - chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); + chrome.runtime + .sendMessage({ type: "OPEN_VAULT" }) + .catch(function () {}); removeDropdown(); }); @@ -398,16 +471,18 @@ // Shared fill action — used by both mousedown and keyboard Enter. function doFill() { - if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); - if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); + if (usernameField && item.plain && item.plain.username) + fillField(usernameField, item.plain.username); + if (pwField && item.plain && item.plain.password) + fillField(pwField, item.plain.password); // ✓ Filled flash: replace row content briefly before closing. row.innerHTML = '
' + '' + '' + - 'Filled
'; - row.style.background = '#f0fdf4'; + "Filled"; + row.style.background = "#f0fdf4"; setTimeout(function () { removeDropdown(); @@ -415,7 +490,7 @@ }, 600); } - row.addEventListener('mousedown', function (e) { + row.addEventListener("mousedown", function (e) { if (e.target === editBtn || editBtn.contains(e.target)) return; e.preventDefault(); doFill(); @@ -429,33 +504,37 @@ } // Divider + "More options…" footer — always shown. - var divider = document.createElement('div'); - divider.style.cssText = 'height:1px;background:#e8eaed;'; + var divider = document.createElement("div"); + divider.style.cssText = "height:1px;background:#e8eaed;"; dropdown.appendChild(divider); - var more = document.createElement('div'); + var more = document.createElement("div"); Object.assign(more.style, { - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '10px 14px', - cursor: 'pointer', - color: '#202124', - fontSize: '13px', - transition: 'background 0.1s', + display: "flex", + alignItems: "center", + gap: "10px", + padding: "10px 14px", + cursor: "pointer", + color: "#202124", + fontSize: "13px", + transition: "background 0.1s", }); - more.onmouseenter = function () { more.style.background = '#f1f3f4'; }; - more.onmouseleave = function () { more.style.background = ''; }; + more.onmouseenter = function () { + more.style.background = "#f1f3f4"; + }; + more.onmouseleave = function () { + more.style.background = ""; + }; more.innerHTML = '' + '' + '' + '' + - '' + + "" + 'More options\u2026'; - more.addEventListener('mousedown', function (e) { + more.addEventListener("mousedown", function (e) { e.preventDefault(); - showDropdown(anchorField, pwField, filterText, 'more'); + showDropdown(anchorField, pwField, filterText, "more"); }); dropdown.appendChild(more); } @@ -464,28 +543,32 @@ function buildMorePanel(dropdown, anchorField, pwField, items, filterText) { // Back header. - var backRow = document.createElement('div'); + var backRow = document.createElement("div"); Object.assign(backRow.style, { - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '10px 14px', - cursor: 'pointer', - color: '#1a73e8', - fontSize: '13px', - fontWeight: '600', - borderBottom: '1px solid #e8eaed', - transition: 'background 0.1s', + display: "flex", + alignItems: "center", + gap: "6px", + padding: "10px 14px", + cursor: "pointer", + color: "#1a73e8", + fontSize: "13px", + fontWeight: "600", + borderBottom: "1px solid #e8eaed", + transition: "background 0.1s", }); - backRow.onmouseenter = function () { backRow.style.background = '#f1f3f4'; }; - backRow.onmouseleave = function () { backRow.style.background = ''; }; + backRow.onmouseenter = function () { + backRow.style.background = "#f1f3f4"; + }; + backRow.onmouseleave = function () { + backRow.style.background = ""; + }; backRow.innerHTML = '' + '' + - ' Back'; - backRow.addEventListener('mousedown', function (e) { + " Back"; + backRow.addEventListener("mousedown", function (e) { e.preventDefault(); - showDropdown(anchorField, pwField, filterText, 'credentials'); + showDropdown(anchorField, pwField, filterText, "credentials"); }); dropdown.appendChild(backRow); @@ -493,67 +576,90 @@ var menuItems = [ { icon: '', - label: 'Report a problem', - action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, + label: "Report a problem", + action: function () { + chrome.runtime + .sendMessage({ type: "OPEN_VAULT" }) + .catch(function () {}); + removeDropdown(); + }, }, { icon: '', - label: 'Generate a password', + label: "Generate a password", chevron: true, - action: function () { chrome.runtime.sendMessage({ type: 'OPEN_GENERATOR' }).catch(function () { }); removeDropdown(); }, + action: function () { + chrome.runtime + .sendMessage({ type: "OPEN_GENERATOR" }) + .catch(function () {}); + removeDropdown(); + }, }, { icon: '', - label: 'Open my vault', - action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, + label: "Open my vault", + action: function () { + chrome.runtime + .sendMessage({ type: "OPEN_VAULT" }) + .catch(function () {}); + removeDropdown(); + }, }, ]; menuItems.forEach(function (item) { - var row = document.createElement('div'); + var row = document.createElement("div"); Object.assign(row.style, { - display: 'flex', - alignItems: 'center', - gap: '12px', - padding: '11px 14px', - cursor: 'pointer', - color: '#202124', - fontSize: '13px', - transition: 'background 0.1s', - borderBottom: '1px solid #f3f4f6', + display: "flex", + alignItems: "center", + gap: "12px", + padding: "11px 14px", + cursor: "pointer", + color: "#202124", + fontSize: "13px", + transition: "background 0.1s", + borderBottom: "1px solid #f3f4f6", }); - row.onmouseenter = function () { row.style.background = '#f1f3f4'; }; - row.onmouseleave = function () { row.style.background = ''; }; + row.onmouseenter = function () { + row.style.background = "#f1f3f4"; + }; + row.onmouseleave = function () { + row.style.background = ""; + }; - var iconWrap = document.createElement('div'); - iconWrap.style.cssText = 'width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;'; - iconWrap.innerHTML = '' + item.icon + ''; + var iconWrap = document.createElement("div"); + iconWrap.style.cssText = + "width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"; + iconWrap.innerHTML = + '' + + item.icon + + ""; - var label = document.createElement('span'); - label.style.cssText = 'flex:1;'; + var label = document.createElement("span"); + label.style.cssText = "flex:1;"; label.textContent = item.label; row.appendChild(iconWrap); row.appendChild(label); if (item.chevron) { - var chev = document.createElement('div'); + var chev = document.createElement("div"); chev.innerHTML = '' + '' + - ''; + ""; row.appendChild(chev); } else { - var extIcon = document.createElement('div'); + var extIcon = document.createElement("div"); extIcon.innerHTML = '' + '' + '' + - ''; + ""; row.appendChild(extIcon); } - row.addEventListener('mousedown', function (e) { + row.addEventListener("mousedown", function (e) { e.preventDefault(); item.action(); }); @@ -569,7 +675,7 @@ function decorateField(field, pwField) { if (field.getAttribute(PK_ATTR)) return; - field.setAttribute(PK_ATTR, '1'); + field.setAttribute(PK_ATTR, "1"); // Cancel any previous listeners on this field. const prevAC = _fieldAbortMap.get(field); @@ -582,101 +688,155 @@ } function createIconBtn(field, pwField, abortSignal) { - const btn = document.createElement('button'); - btn.type = 'button'; + const btn = document.createElement("button"); + btn.type = "button"; btn.className = PK_BTN_CLASS; - btn.title = 'PassKeeper autofill'; - btn.setAttribute('aria-label', 'Autofill with PassKeeper'); + btn.title = "PassKeeper autofill"; + btn.setAttribute("aria-label", "Autofill with PassKeeper"); btn.style.cssText = [ - 'position:fixed', - 'width:26px', - 'height:26px', - 'background:#c0392b', - 'border:none', - 'border-radius:5px', - 'cursor:pointer', - 'display:flex', - 'align-items:center', - 'justify-content:center', - 'z-index:2147483646', - 'padding:0', - 'box-shadow:0 1px 4px rgba(0,0,0,0.3)', - 'transition:background 0.15s', - ].join(';'); + "position:fixed", + "width:26px", + "height:26px", + "background:#c0392b", + "border:none", + "border-radius:5px", + "cursor:pointer", + "display:flex", + "align-items:center", + "justify-content:center", + "z-index:2147483646", + "padding:0", + "box-shadow:0 1px 4px rgba(0,0,0,0.3)", + "transition:background 0.15s", + ].join(";"); btn.innerHTML = '' + '' + '' + '' + - ''; + ""; - btn.addEventListener('mouseenter', function () { btn.style.background = '#a93226'; }); - btn.addEventListener('mouseleave', function () { btn.style.background = '#c0392b'; }); + btn.addEventListener("mouseenter", function () { + btn.style.background = "#a93226"; + }); + btn.addEventListener("mouseleave", function () { + btn.style.background = "#c0392b"; + }); positionBtn(btn, field); document.body.appendChild(btn); _fieldBtnMap.set(field, btn); // Remove the button when the AbortController fires (re-decoration). - abortSignal.addEventListener('abort', function () { + abortSignal.addEventListener("abort", function () { btn.remove(); _fieldBtnMap.delete(field); }); // Keep button tracked as page scrolls/resizes. - function reposition() { if (document.body.contains(btn)) positionBtn(btn, field); } - window.addEventListener('scroll', reposition, { passive: true, signal: abortSignal }); - window.addEventListener('resize', reposition, { passive: true, signal: abortSignal }); + function reposition() { + if (document.body.contains(btn)) positionBtn(btn, field); + } + window.addEventListener("scroll", reposition, { + passive: true, + signal: abortSignal, + }); + window.addEventListener("resize", reposition, { + passive: true, + signal: abortSignal, + }); // ── All event handlers read _matchingItems at call time, never from closure ── // Show dropdown on focus — reads vault_items fresh from storage each time. - field.addEventListener('focus', function () { - showDropdown(field, pwField, field.value, 'credentials'); - }, { signal: abortSignal }); + field.addEventListener( + "focus", + function () { + showDropdown(field, pwField, field.value, "credentials"); + }, + { signal: abortSignal }, + ); // Re-filter as user types — debounced to avoid rebuilding the dropdown // on every single keystroke (noticeable on large vaults or slow machines). var _debouncedShow = _debounce(function () { - showDropdown(field, pwField, field.value, 'credentials'); + showDropdown(field, pwField, field.value, "credentials"); }, 150); - field.addEventListener('input', _debouncedShow, { signal: abortSignal }); + field.addEventListener("input", _debouncedShow, { signal: abortSignal }); // Dim button when field loses focus and no dropdown is open. - field.addEventListener('blur', function () { - setTimeout(function () { - if (!document.getElementById(PK_DROPDOWN_ID)) btn.style.opacity = '0.4'; - }, 150); - }, { signal: abortSignal }); + field.addEventListener( + "blur", + function () { + setTimeout(function () { + if (!document.getElementById(PK_DROPDOWN_ID)) + btn.style.opacity = "0.4"; + }, 150); + }, + { signal: abortSignal }, + ); - field.addEventListener('focus', function () { - btn.style.opacity = '1'; - }, { signal: abortSignal }); + field.addEventListener( + "focus", + function () { + btn.style.opacity = "1"; + }, + { signal: abortSignal }, + ); // Icon click: toggle dropdown. - btn.addEventListener('mousedown', function (e) { + btn.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); - if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } - else { showDropdown(field, pwField, field.value, 'credentials'); } + if (document.getElementById(PK_DROPDOWN_ID)) { + removeDropdown(); + } else { + showDropdown(field, pwField, field.value, "credentials"); + } }); return btn; } - async function decorateFields() { - // Read from chrome.storage.session — memory-only, cleared on browser close. - // Decrypted vault data must never be written to persistent (local) storage. - var all = []; - try { - var result = await chrome.storage.session.get('vault_items_cs'); - all = (result && result.vault_items_cs) || []; - } catch (e) { } - - _matchingItems = _filterForHost(all); - console.log('[PassKeeper] decorateFields: host=' + location.hostname.replace(/^www\./, '') + - ', matched=' + _matchingItems.length + ' of ' + all.length + ' items'); + /** + * Decorate all visible password (and paired username) fields with the PassKeeper + * icon button. + * + * @param {Array|null} knownItems When the caller already holds the correct + * filtered item list (e.g. from a storage.onChanged newValue or a VAULT_UPDATED + * message payload), pass it here to skip the redundant storage read. + * Pass nothing/undefined to let this function read storage itself. + */ + async function decorateFields(knownItems) { + if (knownItems != null) { + // Caller supplied pre-filtered items — trust them, skip the storage read. + _matchingItems = knownItems; + console.log( + "[PassKeeper] decorateFields (inline): host=" + + location.hostname.replace(/^www\./, "") + + ", matched=" + + _matchingItems.length, + ); + } else { + // Read from chrome.storage.session — memory-only, cleared on browser close. + // Decrypted vault data must never be written to persistent (local) storage. + var all = []; + try { + var result = await chrome.storage.session.get("vault_items_cs"); + all = (result && result.vault_items_cs) || []; + } catch (e) {} + _matchingItems = _filterForHost(all); + console.log( + "[PassKeeper] decorateFields (storage): host=" + + location.hostname.replace(/^www\./, "") + + ", matched=" + + _matchingItems.length + + " of " + + all.length + + " items", + ); + } // Decorate every visible password field and its paired username field. visiblePasswordFields().forEach(function (pwField) { @@ -696,23 +856,28 @@ function _normaliseUrl(raw) { if (!raw) return null; var s = raw.trim(); - if (/^https?:\/\//i.test(s)) return s; // already has a scheme - if (s.startsWith('//')) return 'https:' + s; // protocol-relative - return 'https://' + s; // bare domain or path + if (/^https?:\/\//i.test(s)) return s; // already has a scheme + if (s.startsWith("//")) return "https:" + s; // protocol-relative + return "https://" + s; // bare domain or path } function _filterForHost(items) { - var host = location.hostname.replace(/^www\./, ''); + var host = location.hostname.replace(/^www\./, ""); return (items || []).filter(function (item) { - if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false; + if (item.item_type !== "password" || !(item.plain && item.plain.url)) + return false; try { var normalised = _normaliseUrl(item.plain.url); if (!normalised) return false; - var h = new URL(normalised).hostname.replace(/^www\./, ''); + var h = new URL(normalised).hostname.replace(/^www\./, ""); // Match exact domain or any subdomain relationship. - return h === host || h.endsWith('.' + host) || host.endsWith('.' + h); + return h === host || h.endsWith("." + host) || host.endsWith("." + h); } catch (e) { - console.warn('[PassKeeper] _filterForHost: could not parse URL:', item.plain.url, e.message); + console.warn( + "[PassKeeper] _filterForHost: could not parse URL:", + item.plain.url, + e.message, + ); return false; } }); @@ -724,7 +889,9 @@ if (_hasNotifiedForm) return; if (!visiblePasswordFields().length) return; _hasNotifiedForm = true; - chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(function () { }); + chrome.runtime + .sendMessage({ type: "FORMS_DETECTED" }) + .catch(function () {}); } // ── Duplicate detection ─────────────────────────────────────────────────────── @@ -732,29 +899,37 @@ async function classifyCredentials(username, password) { var all = []; try { - var result = await chrome.storage.session.get('vault_items_cs'); + var result = await chrome.storage.session.get("vault_items_cs"); all = (result && result.vault_items_cs) || []; - } catch (e) { return 'new'; } - if (!all.length) return 'new'; + } catch (e) { + return "new"; + } + if (!all.length) return "new"; var siteItems = _filterForHost(all); - if (!siteItems.length) return 'new'; + if (!siteItems.length) return "new"; var exactMatch = siteItems.some(function (item) { - return item.plain && item.plain.username === username && item.plain.password === password; + return ( + item.plain && + item.plain.username === username && + item.plain.password === password + ); }); - return exactMatch ? 'same' : 'updated'; + return exactMatch ? "same" : "updated"; } // ── Save blocklist ──────────────────────────────────────────────────────────── - const BLOCKLIST_KEY = 'save_blocklist'; + const BLOCKLIST_KEY = "save_blocklist"; async function isBlocked(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; return list.indexOf(hostname) !== -1; - } catch (e) { return false; } + } catch (e) { + return false; + } } async function addToBlocklist(hostname) { @@ -764,9 +939,9 @@ if (list.indexOf(hostname) === -1) { list.push(hostname); await chrome.storage.local.set({ [BLOCKLIST_KEY]: list }); - console.log('[PassKeeper] Added to save blocklist:', hostname); + console.log("[PassKeeper] Added to save blocklist:", hostname); } - } catch (e) { } + } catch (e) {} } // ── Auto-save banner ────────────────────────────────────────────────────────── @@ -774,120 +949,192 @@ function showSaveBanner(username, password, credentialState) { if (_bannerEl) _bannerEl.remove(); - var banner = document.createElement('div'); - banner.id = '__pk_save_banner__'; + var banner = document.createElement("div"); + banner.id = "__pk_save_banner__"; Object.assign(banner.style, { - position: 'fixed', - top: '12px', - right: '12px', - zIndex: '2147483647', - background: '#ffffff', - border: '1px solid #e2e8f0', - borderRadius: '10px', - boxShadow: '0 8px 30px rgba(0,0,0,0.15)', - padding: '14px 16px 12px', - fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", - fontSize: '13px', - color: '#1a1a2e', - maxWidth: '300px', - minWidth: '240px', + position: "fixed", + top: "12px", + right: "12px", + zIndex: "2147483647", + background: "#ffffff", + border: "1px solid #e2e8f0", + borderRadius: "10px", + boxShadow: "0 8px 30px rgba(0,0,0,0.15)", + padding: "14px 16px 12px", + fontFamily: + "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", + fontSize: "13px", + color: "#1a1a2e", + maxWidth: "300px", + minWidth: "240px", }); var site = escHtml(location.hostname); var user = escHtml(username); - var title = credentialState === 'updated' ? 'Update in PassKeeper?' : 'Save to PassKeeper?'; + var title = + credentialState === "updated" + ? "Update in PassKeeper?" + : "Save to PassKeeper?"; + // Default site name: prefer page title (trimmed to 60 chars), fall back to hostname. + var defaultSiteName = + (document.title || "").trim().slice(0, 60) || location.hostname; banner.innerHTML = '
' + '' + - '' + escHtml(title) + '' + + '' + + escHtml(title) + + "" + '' + - '
' + + "" + + '
' + + '' + + '' + + "
" + '

' + - '' + user + ' on ' + site + '' + - '

' + + '' + + user + + ' on ' + + site + + "" + + "

" + '
' + '' + '' + - '
' + - ''; + "" + + '"; document.body.appendChild(banner); _bannerEl = banner; - var dismiss = function () { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; - banner.querySelector('#__pk_close__').addEventListener('click', dismiss); - banner.querySelector('#__pk_skip__').addEventListener('click', dismiss); - banner.querySelector('#__pk_save__').addEventListener('click', function () { - console.log('[PassKeeper] User chose to save credentials for', location.hostname); - chrome.runtime.sendMessage({ - type: 'SAVE_CREDENTIALS', - data: { url: location.href, siteName: document.title || location.hostname, username: username, password: password }, - }).catch(function () { }); - dismiss(); - }); - banner.querySelector('#__pk_never__').addEventListener('click', function () { - addToBlocklist(location.hostname); + var dismiss = function () { + if (_bannerEl === banner) { + banner.remove(); + _bannerEl = null; + } + }; + banner.querySelector("#__pk_close__").addEventListener("click", dismiss); + banner.querySelector("#__pk_skip__").addEventListener("click", dismiss); + banner.querySelector("#__pk_save__").addEventListener("click", function () { + var siteName = + (banner.querySelector("#__pk_site_name__").value || "").trim() || + location.hostname; + console.log( + "[PassKeeper] User chose to save credentials for", + location.hostname, + "— site name:", + siteName, + ); + chrome.runtime + .sendMessage({ + type: "SAVE_CREDENTIALS", + data: { + url: location.href, + siteName: siteName, + username: username, + password: password, + }, + }) + .catch(function () {}); dismiss(); }); + banner + .querySelector("#__pk_never__") + .addEventListener("click", function () { + addToBlocklist(location.hostname); + dismiss(); + }); } // ── Form submission watch ───────────────────────────────────────────────────── function watchSubmissions() { - document.addEventListener('submit', async function (e) { - var form = e.target; - var pwField = form.querySelector('input[type="password"]:not([disabled])'); - if (!pwField || !pwField.value) return; + document.addEventListener( + "submit", + async function (e) { + var form = e.target; + var pwField = form.querySelector( + 'input[type="password"]:not([disabled])', + ); + if (!pwField || !pwField.value) return; - var userField = findUsernameField(pwField) - || form.querySelector('input[type="email"]:not([disabled])') - || form.querySelector('input[type="text"]:not([disabled])'); + var userField = + findUsernameField(pwField) || + form.querySelector('input[type="email"]:not([disabled])') || + form.querySelector('input[type="text"]:not([disabled])'); - var username = (userField && userField.value && userField.value.trim()) || ''; - var password = pwField.value; - if (!username || !password) return; + var username = + (userField && userField.value && userField.value.trim()) || ""; + var password = pwField.value; + if (!username || !password) return; - removeDropdown(); + removeDropdown(); - // Check blocklist before doing anything else. - if (await isBlocked(location.hostname)) { - console.log('[PassKeeper] Site is blocklisted, skipping save banner:', location.hostname); - return; - } + // Check blocklist before doing anything else. + if (await isBlocked(location.hostname)) { + console.log( + "[PassKeeper] Site is blocklisted, skipping save banner:", + location.hostname, + ); + return; + } - var credentialState = await classifyCredentials(username, password); - console.log('[PassKeeper] Credential state for', location.hostname, '\u2192', credentialState); - if (credentialState === 'same') return; + var credentialState = await classifyCredentials(username, password); + console.log( + "[PassKeeper] Credential state for", + location.hostname, + "\u2192", + credentialState, + ); + if (credentialState === "same") return; - setTimeout(function () { showSaveBanner(username, password, credentialState); }, 500); - }, true); + setTimeout(function () { + showSaveBanner(username, password, credentialState); + }, 500); + }, + true, + ); } // ── Message listener ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener(function (msg, _sender, sendResponse) { - if (msg.type === 'DO_AUTOFILL') { + if (msg.type === "DO_AUTOFILL") { doAutofill(msg.username, msg.password); sendResponse({ ok: true }); } - if (msg.type === 'VAULT_UPDATED') { - // Items may arrive in the message payload (best-effort), but the source - // of truth is now chrome.storage.local which was already written by the popup. + if (msg.type === "VAULT_UPDATED") { + // Items arrive in the message payload. The source of truth is + // chrome.storage.session (vault_items_cs), which was already written by + // the popup before this message was sent. var allItems = msg.vault_items || []; - if (allItems.length) { - _matchingItems = _filterForHost(allItems); - console.log('[PassKeeper] VAULT_UPDATED (message): matched=' + _matchingItems.length + ' of ' + allItems.length); + var matched = allItems.length ? _filterForHost(allItems) : null; + if (matched !== null) { + _matchingItems = matched; + console.log( + "[PassKeeper] VAULT_UPDATED (message): matched=" + + _matchingItems.length + + " of " + + allItems.length, + ); } - // Always re-decorate (also re-reads storage if message had no items). - document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { + // Re-decorate, passing pre-filtered items so decorateFields skips the + // storage read when the message payload was non-empty. + document.querySelectorAll("[" + PK_ATTR + "]").forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); - document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); + document.querySelectorAll("." + PK_BTN_CLASS).forEach(function (el) { + el.remove(); + }); removeDropdown(); - decorateFields(); + decorateFields(matched); } return false; }); @@ -899,22 +1146,32 @@ decorateFields(); watchSubmissions(); - // React instantly when the popup writes fresh vault data to local storage. + // React instantly when the popup writes fresh vault data to session storage. // This fires in the same tick as the write — no message delivery required. chrome.storage.onChanged.addListener(function (changes, area) { - if (area === 'session' && changes.vault_items_cs) { - var allItems = (changes.vault_items_cs.newValue) || []; - _matchingItems = _filterForHost(allItems); - console.log('[PassKeeper] storage.onChanged: matched=' + _matchingItems.length + ' of ' + allItems.length + ' items'); - // Re-decorate all fields with the fresh items. - document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { + if (area === "session" && changes.vault_items_cs) { + var allItems = changes.vault_items_cs.newValue || []; + var matched = _filterForHost(allItems); + _matchingItems = matched; + console.log( + "[PassKeeper] storage.onChanged: matched=" + + matched.length + + " of " + + allItems.length + + " items", + ); + // Re-decorate, passing the already-filtered list so decorateFields + // skips a redundant storage.session.get() call. + document.querySelectorAll("[" + PK_ATTR + "]").forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); - document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); + document.querySelectorAll("." + PK_BTN_CLASS).forEach(function (el) { + el.remove(); + }); removeDropdown(); - decorateFields(); + decorateFields(matched); } }); @@ -923,17 +1180,20 @@ // (dropdown, icon buttons, save banner) to prevent re-decoration loops // on SPAs that react to every DOM change. var ownMutation = mutations.every(function (m) { - return Array.from(m.addedNodes).concat(Array.from(m.removedNodes)).every(function (node) { - if (!node || node.nodeType !== 1) return true; - var cls = (node.className || ''); - var id = (node.id || ''); - return cls.indexOf('__pk') !== -1 || - id.indexOf('__pk') !== -1 || - node.querySelector && ( - node.querySelector('.' + PK_BTN_CLASS) || - node.querySelector('#' + PK_DROPDOWN_ID) - ); - }); + return Array.from(m.addedNodes) + .concat(Array.from(m.removedNodes)) + .every(function (node) { + if (!node || node.nodeType !== 1) return true; + var cls = node.className || ""; + var id = node.id || ""; + return ( + cls.indexOf("__pk") !== -1 || + id.indexOf("__pk") !== -1 || + (node.querySelector && + (node.querySelector("." + PK_BTN_CLASS) || + node.querySelector("#" + PK_DROPDOWN_ID))) + ); + }); }); if (ownMutation) return; _hasNotifiedForm = false; @@ -943,9 +1203,9 @@ _formObserver.observe(document.body, { childList: true, subtree: true }); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); } else { init(); } -})(); \ No newline at end of file +})(); diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh index 7aedc42..c92acb9 100644 --- a/scripts/backup_db.sh +++ b/scripts/backup_db.sh @@ -56,11 +56,14 @@ log "INFO Starting backup → $BACKUP_FILE" # --single-transaction: consistent snapshot without locking (InnoDB) # --routines --events: include stored procedures/events if any # --no-tablespaces: avoid PROCESS privilege requirement on MySQL 8+ -mysqldump \ +# +# MYSQL_PWD is used instead of --password so the credential never appears in +# the process list (ps aux) where any user on the server could read it. +# mysqldump and the mysql client both honour MYSQL_PWD natively. +MYSQL_PWD="$DB_PASS" mysqldump \ --host="$DB_HOST" \ --port="$DB_PORT" \ --user="$DB_USER" \ - --password="$DB_PASS" \ --single-transaction \ --routines \ --events \ @@ -82,4 +85,4 @@ DELETED=$(find "$BACKUP_DIR" -maxdepth 1 -name 'passkeeper_*.sql.gz' \ -mtime +"$RETENTION_DAYS" -print -delete | wc -l) log "INFO Retention cleanup: removed $DELETED file(s) older than ${RETENTION_DAYS} days" -log "INFO Backup finished successfully" +log "INFO Backup finished successfully" \ No newline at end of file