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 = '';
-
- 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 =
'';
- 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 = '';
+ 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 =
+ '";
- 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