Newer
Older
gnexus-creds / extensions / extension / src / content.js
/**
 * Content script for login form detection, autofill, and save prompt.
 */

let secretsForDomain = [];
let shownCards = new WeakSet();
let autofillHost = null;
let autofillShadow = null;
let cardStylesPromise = null;

// --- Messaging helper ---

function send(type, payload) {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage({ type, payload }, (response) => {
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
        return;
      }
      if (!response || !response.ok) {
        reject(new Error(response?.error || "Unknown error"));
        return;
      }
      resolve(response.data);
    });
  });
}

// --- Debounce helper ---

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

// --- Domain matching ---

async function fetchSecretsForDomain() {
  try {
    const domain = location.hostname;
    secretsForDomain = await send("GET_SECRETS_FOR_DOMAIN", { domain });
  } catch {
    secretsForDomain = [];
  }
}

// --- Login-field detection ---
// DOM probing lives here; the scoring itself is pure string logic in
// field-detect.js (loaded before this file — manifest content_scripts order —
// and unit-tested with plain node via tools/test-field-detect.js).

function isVisible(input) {
  try {
    if (typeof input.checkVisibility === "function") {
      return input.checkVisibility();
    }
  } catch {
    // fall through
  }
  return !!input.getClientRects().length;
}

function makeFieldDescriptor(el, index) {
  const labelText = [];
  for (const label of el.labels || []) {
    labelText.push(label.textContent || "");
  }
  const labelledBy = el.getAttribute("aria-labelledby");
  if (labelledBy) {
    for (const id of labelledBy.split(/\s+/)) {
      const node = id && document.getElementById(id);
      if (node) labelText.push(node.textContent || "");
    }
  }
  return {
    el,
    index,
    type: (el.type || "").toLowerCase(),
    name: (el.name || "").toLowerCase(),
    id: (el.id || "").toLowerCase(),
    placeholder: (el.getAttribute("placeholder") || "").toLowerCase(),
    autocomplete: (el.getAttribute("autocomplete") || "").toLowerCase(),
    ariaLabel: (el.getAttribute("aria-label") || "").toLowerCase(),
    labelText: labelText.join(" ").toLowerCase(),
    visible: isVisible(el),
    disabled: !!el.disabled,
    readonly: !!el.readOnly,
    formEl: el.closest("form"),
  };
}

function findLoginFields() {
  const inputs = document.querySelectorAll(
    'input[type="password"], input[type="text"], input[type="email"], input[type="tel"], input[type="search"], input:not([type])'
  );
  const fields = Array.from(inputs).map(makeFieldDescriptor);
  return GnCredsDetect.findLoginTargets(fields);
}

// --- Autofill card ---
// The card lives in a CLOSED shadow root on a bare host element, so the host
// page cannot restyle it (CSS does not cross the shadow boundary), read its
// content (secret titles) via the DOM, or reach its buttons through
// querySelector. Styles are fetched from our own web-accessible content.css
// and injected as a <style> inside the shadow root — never added to the page.

const CARD_CSS_URL = chrome.runtime.getURL("src/content.css");

function getCardStyles() {
  if (!cardStylesPromise) {
    cardStylesPromise = fetch(CARD_CSS_URL)
      .then((r) => (r.ok ? r.text() : ""))
      .catch(() => "");
  }
  return cardStylesPromise;
}

function getAutofillShadow() {
  if (autofillShadow) return autofillShadow;
  autofillHost = document.createElement("div");
  autofillHost.dataset.gnexusCreds = "root";
  autofillHost.style.cssText = "position:absolute;width:0;height:0;";
  document.body.appendChild(autofillHost);
  autofillShadow = autofillHost.attachShadow({ mode: "closed" });
  return autofillShadow;
}

function removeAutofillCard(card, key) {
  card.classList.remove("gnexus-creds-autofill-card-visible");
  card.classList.add("gnexus-creds-autofill-card-exit");
  setTimeout(() => {
    card.remove();
    shownCards.delete(key);
    if (autofillShadow && !autofillShadow.querySelector(".gnexus-creds-autofill-card")) {
      autofillHost.remove();
      autofillHost = null;
      autofillShadow = null;
    }
  }, 280);
}

function getAutofillContainer(shadow) {
  let container = shadow.querySelector(".gnexus-creds-autofill-container");
  if (!container) {
    container = document.createElement("div");
    container.className = "gnexus-creds-autofill-container";
    shadow.appendChild(container);
  }
  return container;
}

async function showAutofillCard(form, usernameInput, passwordInput, secrets) {
  const key = form || passwordInput;
  if (!key || shownCards.has(key)) return;
  shownCards.add(key);

  const shadow = getAutofillShadow();
  const cardCss = await getCardStyles();
  if (!shadow.querySelector("style[data-gnexus-creds-css]")) {
    const style = document.createElement("style");
    style.dataset.gnexusCredsCss = "card";
    style.textContent = cardCss;
    shadow.appendChild(style);
  }
  const container = getAutofillContainer(shadow);

  const card = document.createElement("div");
  card.className = "gnexus-creds-autofill-card";

  if (secrets.length === 1) {
    const secret = secrets[0];
    card.innerHTML = `
      <div class="gnexus-creds-autofill-title">${escapeHtml(secret.title)}</div>
      <div class="gnexus-creds-autofill-actions">
        <button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-primary" data-action="use" data-id="${escapeAttr(secret.id)}">Use</button>
        <button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-secondary" data-action="dismiss">Dismiss</button>
      </div>
    `;
  } else {
    const rows = secrets.map((s) => `
      <div class="gnexus-creds-autofill-row">
        <div class="gnexus-creds-autofill-row-title">${escapeHtml(s.title)}</div>
        <button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-primary" data-action="use" data-id="${escapeAttr(s.id)}">Use</button>
      </div>
    `).join("");
    card.innerHTML = `
      <div class="gnexus-creds-autofill-title">Select account</div>
      <div class="gnexus-creds-autofill-list">${rows}</div>
      <div class="gnexus-creds-autofill-actions">
        <button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-secondary" data-action="dismiss">Dismiss</button>
      </div>
    `;
  }

  container.appendChild(card);
  requestAnimationFrame(() => {
    card.classList.add("gnexus-creds-autofill-card-visible");
  });

  card.addEventListener("click", async (e) => {
    const btn = e.target.closest('[data-action="use"]');
    if (!btn) return;
    const secretId = btn.dataset.id;
    try {
      const revealed = await send("REVEAL_SECRET", { id: secretId });
      const fields = revealed.fields || [];
      const userField = fields.find((f) => /user|login|email/i.test(f.name));
      const passField = fields.find((f) => /pass|password|пароль/i.test(f.name));
      if (userField && usernameInput) {
        usernameInput.value = userField.value || "";
        triggerInputEvents(usernameInput);
      }
      if (passField && passwordInput) {
        passwordInput.value = passField.value || "";
        triggerInputEvents(passwordInput);
      }
    } catch (err) {
      console.error("[gnexus-creds] autofill failed:", err.message);
    }
    removeAutofillCard(card, key);
  });

  const dismissBtn = card.querySelector('[data-action="dismiss"]');
  if (dismissBtn) {
    dismissBtn.addEventListener("click", () => {
      removeAutofillCard(card, key);
    });
  }
}

function triggerInputEvents(input) {
  input.dispatchEvent(new Event("input", { bubbles: true }));
  input.dispatchEvent(new Event("change", { bubbles: true }));
}

// --- Save prompt (persistent via background) ---

function isAlreadySaved(domain, username) {
  if (!secretsForDomain.length) return false;
  if (username) {
    return secretsForDomain.some((s) => (s.title || "").includes(username));
  }
  return secretsForDomain.some((s) => (s.source || "").includes(domain) || (s.title || "").includes(domain));
}

function notifyPendingSave(username, password) {
  const domain = location.hostname;
  if (isAlreadySaved(domain, username)) return;
  const title = `${domain} — ${username || "account"}`;
  chrome.runtime.sendMessage(
    {
      type: "PENDING_SAVE",
      payload: {
        title,
        source: domain,
        fields: [
          { name: "username", value: username || "", encrypted: true, masked: false },
          { name: "password", value: password || "", encrypted: true, masked: true },
        ],
        category: "web",
        tags: ["autofill", "browser-extension", "credentials"],
        allow_ui: true,
        allow_rest_api: true,
      },
    },
    () => {
      // Ignore port-closed errors when the page navigates away
      if (chrome.runtime.lastError) {
        /* ignore */
      }
    }
  );
}

// --- Intercept form submit ---

function attachSubmitInterceptor(form, usernameInput, passwordInput) {
  if (form) {
    form.addEventListener("submit", () => {
      const username = usernameInput?.value?.trim() || "";
      const password = passwordInput?.value?.trim() || "";
      if (!password) return;
      notifyPendingSave(username, password);
    }, true);
  } else {
    // No <form> tag: intercept Enter key on password input
    // and clicks on nearby submit-like buttons
    passwordInput.addEventListener("keydown", (e) => {
      if (e.key !== "Enter") return;
      const username = usernameInput?.value?.trim() || "";
      const password = passwordInput?.value?.trim() || "";
      if (!password) return;
      notifyPendingSave(username, password);
    });

    // Try to find a submit button near the password field
    let container = passwordInput.parentElement;
    let depth = 0;
    while (container && depth < 3) {
      const btn = container.querySelector('button[type="submit"], input[type="submit"], button:not([type])');
      if (btn) {
        btn.addEventListener("click", () => {
          const username = usernameInput?.value?.trim() || "";
          const password = passwordInput?.value?.trim() || "";
          if (!password) return;
          notifyPendingSave(username, password);
        });
        break;
      }
      container = container.parentElement;
      depth++;
    }
  }
}

// --- Main flow ---

async function scanPage() {
  await fetchSecretsForDomain();
  // handled passwords are marked here — a password can gain its interceptor
  // and card only once per page load
  const targets = findLoginFields().filter(
    ({ password }) => password.el.dataset.gnexusCreds !== "handled"
  );
  for (const { password, username, context } of targets) {
    password.el.dataset.gnexusCreds = "handled";
    // Autofill card
    if (secretsForDomain.length > 0 && username) {
      await showAutofillCard(context, username.el, password.el, secretsForDomain);
    }
    // Save interceptor
    attachSubmitInterceptor(context, username?.el || null, password.el);
  }
}

const debouncedScan = debounce(scanPage, 300);

// Observe DOM changes for SPA-like navigation — filter for password inputs
const observer = new MutationObserver((mutations) => {
  let hasPassword = false;
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;
      if (node.matches?.('input[type="password"]') || node.querySelector?.('input[type="password"]')) {
        hasPassword = true;
        break;
      }
    }
    if (hasPassword) break;
  }
  if (hasPassword) debouncedScan();
});
observer.observe(document.documentElement, { childList: true, subtree: true });

// Initial scan
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", scanPage);
} else {
  scanPage();
}

function escapeHtml(text) {
  if (text == null) return "";
  const div = document.createElement("div");
  div.textContent = String(text);
  return div.innerHTML;
}

function escapeAttr(text) {
  if (text == null) return "";
  return String(text)
    .replace(/&/g, "&amp;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}