Spaces:
Sleeping
Sleeping
| // ============================================================ | |
| // PhishGuard AI - popup.js | |
| // Popup logic: displays verdict, feedback buttons, retraining | |
| // status, and session stats. | |
| // ============================================================ | |
| (function() { | |
| "use strict"; | |
| // ββ DOM Elements ββββββββββββββββββββββββββββββββββββββββββββββ | |
| const $id = id => document.getElementById(id); | |
| const loadingState = $id("loadingState"); | |
| const resultPanel = $id("resultPanel"); | |
| const feedbackSection = $id("feedbackSection"); | |
| const retrainSection = $id("retrainSection"); | |
| const statsRow = $id("statsRow"); | |
| const blockedOverlay = $id("blockedOverlay"); | |
| const offlineBanner = $id("offlineBanner"); | |
| let currentResult = null; | |
| let currentUrlHash = null; | |
| let feedbackGiven = false; | |
| let feedbackTimeout = null; | |
| let countdownInterval = null; | |
| // ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function init() { | |
| // Check if this is a blocked page redirect | |
| const params = new URLSearchParams(window.location.search); | |
| if (params.get("blocked") === "1") { | |
| showBlockedPage(params); | |
| return; | |
| } | |
| // Get active tab | |
| const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); | |
| if (!tab?.url || !tab.url.startsWith("http")) { | |
| showResult({ | |
| status: "safe", tier: 0, method: "internal", | |
| confidence: 0, url: tab?.url || "N/A" | |
| }); | |
| return; | |
| } | |
| $id("currentUrl").textContent = tab.url; | |
| // Try per-tab cache first (instant) | |
| chrome.runtime.sendMessage( | |
| { type: "get_tab_result", tabId: tab.id }, | |
| response => { | |
| if (response?.result) { | |
| showResult(response.result); | |
| } else { | |
| // Fallback to chrome.storage | |
| chrome.storage.local.get("lastResult", data => { | |
| if (data.lastResult && data.lastResult.url === tab.url) { | |
| showResult(data.lastResult); | |
| } else { | |
| loadingState.style.display = "flex"; | |
| } | |
| }); | |
| } | |
| } | |
| ); | |
| // Load status | |
| loadStatus(); | |
| } | |
| // ββ Show Result βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showResult(result) { | |
| currentResult = result; | |
| loadingState.style.display = "none"; | |
| resultPanel.style.display = "block"; | |
| feedbackSection.style.display = "block"; | |
| retrainSection.style.display = "block"; | |
| statsRow.style.display = "flex"; | |
| const isBlocked = result.status === "blocked" || result.is_phishing; | |
| const isWarn = !isBlocked && (result.confidence || 0) >= 0.4; | |
| const confidence = Math.round((result.confidence || 0) * 100); | |
| const tier = result.tier || 0; | |
| // Score ring | |
| const ring = $id("scoreRing"); | |
| const circumference = 2 * Math.PI * 34; // r=34 | |
| const offset = circumference - (confidence / 100) * circumference; | |
| ring.style.strokeDasharray = circumference; | |
| setTimeout(() => { | |
| ring.style.strokeDashoffset = offset; | |
| ring.style.stroke = isBlocked ? "var(--danger)" : | |
| isWarn ? "var(--warning)" : "var(--safe)"; | |
| }, 100); | |
| $id("scorePct").textContent = confidence + "%"; | |
| $id("scorePct").className = `score-pct ${isBlocked ? "status-danger" : isWarn ? "status-warn" : "status-safe"}`; | |
| $id("scoreSub").textContent = isBlocked ? "THREAT" : "RISK"; | |
| // Shield | |
| $id("shieldIcon").textContent = isBlocked ? "π¨" : isWarn ? "β οΈ" : "β "; | |
| // Verdict text | |
| $id("verdictLabel").textContent = isBlocked ? "PHISHING DETECTED" : | |
| isWarn ? "SUSPICIOUS" : "SAFE"; | |
| $id("verdictLabel").className = `verdict-label ${isBlocked ? "status-danger" : isWarn ? "status-warn" : "status-safe"}`; | |
| const methodNames = { | |
| "whitelist": "Whitelist (Tier 1)", | |
| "heuristic": "Heuristic Engine (Tier 2)", | |
| "heuristic-fallback": "Heuristic Fallback", | |
| "bert_gnn_ensemble": "BERT + GNN Ensemble (Tier 3)", | |
| "full_ensemble_bert_gnn_cnn": "Full ML Ensemble (Tier 4)", | |
| "ensemble_with_visual": "Ensemble + Visual (Tier 4)", | |
| "user-override": "User Override", | |
| }; | |
| const methodText = methodNames[result.method] || result.method || "Unknown"; | |
| $id("verdictDetail").textContent = `${methodText} Β· Confidence: ${confidence}%`; | |
| // Tier dots and scores | |
| updateTierRow(1, tier >= 1 ? "checked" : "pending", tier === 1 ? "SAFE β" : "Miss β"); | |
| updateTierRow(2, tier >= 2 ? (isBlocked && tier === 2 ? "blocked" : "checked") : "pending", | |
| result.heuristic_score != null ? `${result.heuristic_score}/100` : "β"); | |
| updateTierRow(3, tier >= 3 ? (isBlocked && tier === 3 ? "blocked" : "checked") : "pending", | |
| result.details?.tier3_score != null ? (result.details.tier3_score * 100).toFixed(0) + "%" : "β"); | |
| updateTierRow(4, tier >= 4 ? (isBlocked && tier === 4 ? "blocked" : "checked") : "pending", | |
| result.details?.tier4_score != null ? (result.details.tier4_score * 100).toFixed(0) + "%" : "β"); | |
| // Tier 2 details β show triggered signals | |
| if (result.signals && result.signals.length > 0) { | |
| const badges = result.signals.map(s => `<span class="flag-badge">${s}</span>`).join(" "); | |
| $id("t2Detail").innerHTML = `Signals triggered:<br>${badges}`; | |
| } | |
| // Compute URL hash for feedback | |
| computeUrlHash(result.url); | |
| // Check if we already gave feedback | |
| checkExistingFeedback(); | |
| } | |
| function updateTierRow(tier, status, scoreText) { | |
| const dot = $id(`t${tier}Dot`); | |
| const score = $id(`t${tier}Score`); | |
| const colors = { | |
| checked: "var(--safe)", | |
| blocked: "var(--danger)", | |
| pending: "var(--text-muted)", | |
| }; | |
| dot.style.background = colors[status] || colors.pending; | |
| score.textContent = scoreText; | |
| } | |
| // ββ Blocked Page ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showBlockedPage(params) { | |
| loadingState.style.display = "none"; | |
| blockedOverlay.classList.add("show"); | |
| const url = decodeURIComponent(params.get("url") || ""); | |
| const score = params.get("score") || "0"; | |
| const method = decodeURIComponent(params.get("method") || ""); | |
| $id("blockedUrl").textContent = url; | |
| $id("blockedMethod").textContent = `Detection: ${method} Β· Risk: ${score}%`; | |
| $id("currentUrl").textContent = url; | |
| currentResult = { url, status: "blocked", confidence: parseInt(score) / 100, method }; | |
| computeUrlHash(url); | |
| // Show feedback for blocked pages too | |
| feedbackSection.style.display = "block"; | |
| retrainSection.style.display = "block"; | |
| statsRow.style.display = "flex"; | |
| loadStatus(); | |
| $id("proceedBtn").onclick = () => { | |
| chrome.runtime.sendMessage({ type: "whitelist_url", url }, () => { | |
| chrome.tabs.update({ url }); | |
| }); | |
| }; | |
| } | |
| // ββ Feedback ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function computeUrlHash(url) { | |
| if (!url) return; | |
| const encoded = new TextEncoder().encode(url); | |
| const hash = await crypto.subtle.digest("SHA-256", encoded); | |
| currentUrlHash = Array.from(new Uint8Array(hash)) | |
| .map(b => b.toString(16).padStart(2, "0")).join(""); | |
| } | |
| function submitFeedback(feedback) { | |
| if (feedbackGiven || !currentUrlHash) return; | |
| feedbackGiven = true; | |
| chrome.runtime.sendMessage({ | |
| type: "submit_feedback", | |
| url_hash: currentUrlHash, | |
| feedback: feedback, | |
| }, response => { | |
| if (response?.success) { | |
| // Highlight selected, dim other | |
| const correctBtn = $id("btnCorrect"); | |
| const wrongBtn = $id("btnWrong"); | |
| if (feedback === "correct") { | |
| correctBtn.classList.add("selected"); | |
| wrongBtn.classList.add("dimmed"); | |
| } else { | |
| wrongBtn.classList.add("selected"); | |
| correctBtn.classList.add("dimmed"); | |
| } | |
| $id("thankYou").classList.add("show"); | |
| // Allow changing within 5 minutes | |
| feedbackTimeout = setTimeout(() => { | |
| feedbackGiven = false; | |
| correctBtn.classList.remove("selected", "dimmed"); | |
| wrongBtn.classList.remove("selected", "dimmed"); | |
| }, 5 * 60 * 1000); | |
| } | |
| }); | |
| } | |
| function checkExistingFeedback() { | |
| // Check if feedback was already given for this URL | |
| chrome.storage.local.get("phishguard_feedback_queue", data => { | |
| const queue = data.phishguard_feedback_queue || []; | |
| const record = queue.find(r => r.url_hash === currentUrlHash); | |
| if (record?.user_feedback) { | |
| feedbackGiven = true; | |
| const correctBtn = $id("btnCorrect"); | |
| const wrongBtn = $id("btnWrong"); | |
| if (record.user_feedback === "correct") { | |
| correctBtn.classList.add("selected"); | |
| wrongBtn.classList.add("dimmed"); | |
| } else { | |
| wrongBtn.classList.add("selected"); | |
| correctBtn.classList.add("dimmed"); | |
| } | |
| } | |
| }); | |
| } | |
| // ββ Retraining Status βββββββββββββββββββββββββββββββββββββββββ | |
| function loadStatus() { | |
| chrome.runtime.sendMessage({ type: "get_status" }, status => { | |
| if (!status) return; | |
| // Stats row | |
| $id("statScanned").textContent = `π ${status.scan_count} scanned`; | |
| $id("statFeedback").textContent = `π¬ ${status.labeled_count || 0} labeled`; | |
| $id("statVersion").textContent = `π·οΈ v${status.model_version}`; | |
| $id("versionBadge").textContent = `v${status.model_version || "3.0"}`; | |
| // Retrain progress | |
| const urlsRemaining = status.next_retrain_urls_remaining || 50; | |
| const progress = Math.round(((50 - urlsRemaining) / 50) * 100); | |
| $id("retrainProgressBar").style.width = `${progress}%`; | |
| // Retrain status text | |
| const timeMs = status.next_retrain_time_remaining_ms || 0; | |
| const hours = Math.floor(timeMs / 3600000); | |
| const mins = Math.floor((timeMs % 3600000) / 60000); | |
| const labeledNeeded = status.min_labeled_needed || 0; | |
| if (labeledNeeded > 0) { | |
| $id("retrainStatus").textContent = | |
| `Need ${labeledNeeded} more feedback to retrain`; | |
| } else { | |
| $id("retrainStatus").textContent = | |
| `Next retrain: ${urlsRemaining} URLs or ${hours}h ${mins}m`; | |
| } | |
| // Last retrain info | |
| if (status.last_retrain_ts) { | |
| const ago = timeSince(new Date(status.last_retrain_ts)); | |
| $id("retrainLast").textContent = `Last retrain: ${ago} ago`; | |
| } | |
| // Start countdown | |
| startCountdown(timeMs); | |
| }); | |
| } | |
| function startCountdown(initialMs) { | |
| if (countdownInterval) clearInterval(countdownInterval); | |
| let remaining = initialMs; | |
| countdownInterval = setInterval(() => { | |
| remaining -= 1000; | |
| if (remaining <= 0) { | |
| clearInterval(countdownInterval); | |
| $id("retrainStatus").textContent = "Retrain pending..."; | |
| return; | |
| } | |
| const h = Math.floor(remaining / 3600000); | |
| const m = Math.floor((remaining % 3600000) / 60000); | |
| const s = Math.floor((remaining % 60000) / 1000); | |
| // Only update the time portion if it's the time display | |
| const el = $id("retrainStatus"); | |
| if (el.textContent.includes("URLs or")) { | |
| const parts = el.textContent.split(" or "); | |
| el.textContent = `${parts[0]} or ${h}h ${m}m ${s}s`; | |
| } | |
| }, 1000); | |
| } | |
| function timeSince(date) { | |
| const seconds = Math.floor((Date.now() - date.getTime()) / 1000); | |
| if (seconds < 60) return `${seconds}s`; | |
| if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; | |
| if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; | |
| return `${Math.floor(seconds / 86400)}d`; | |
| } | |
| // ββ Tier Row Toggle βββββββββββββββββββββββββββββββββββββββββββ | |
| window.toggleTier = function(header) { | |
| const row = header.parentElement; | |
| row.classList.toggle("open"); | |
| }; | |
| // ββ Event Listeners βββββββββββββββββββββββββββββββββββββββββββ | |
| $id("btnCorrect").addEventListener("click", () => submitFeedback("correct")); | |
| $id("btnWrong").addEventListener("click", () => submitFeedback("incorrect")); | |
| // ββ Start βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| init(); | |
| })(); | |