Spaces:
Sleeping
Sleeping
| // ============================================================ | |
| // SafeAIScan β API Layer v2.0 | |
| // Standardized responses, plan-aware gating, retry logic | |
| // ============================================================ | |
| const BASE_URL = "https://rathious-safeaiscan.hf.space"; | |
| const MAX_RETRIES = 2; | |
| const RETRY_DELAY_MS = 900; | |
| // ---- AUTH HELPERS ---- | |
| const getToken = () => localStorage.getItem("access_token"); | |
| const getApiKey = () => localStorage.getItem("api_key"); | |
| function clearAuth() { | |
| localStorage.clear(); | |
| window.location.replace("login.html"); | |
| } | |
| // ββ Plan helpers βββββββββββββββββββββββββββββββββββββββββββ | |
| // Source of truth: localStorage values set by getMe() after every page load. | |
| // pro_trial counts as full Pro access. | |
| let _userPlan = localStorage.getItem("user_plan") || "free"; | |
| let _userLimits = null; | |
| try { | |
| const s = localStorage.getItem("user_limits"); | |
| if (s) _userLimits = JSON.parse(s); | |
| } catch {} | |
| function isProUser() { | |
| if (localStorage.getItem("is_pro") === "true") return true; | |
| if (localStorage.getItem("trial_active") === "true") return true; | |
| const p = localStorage.getItem("user_plan") || "free"; | |
| return p === "pro_trial" || p === "pro" || p === "enterprise"; | |
| } | |
| function isTrialUser() { | |
| const p = localStorage.getItem("user_plan") || "free"; | |
| return p === "pro_trial" && localStorage.getItem("trial_active") === "true"; | |
| } | |
| function getTrialDaysLeft() { | |
| return parseInt(localStorage.getItem("trial_days_left") || "0"); | |
| } | |
| function getUserPlan() { | |
| return _userPlan; | |
| } | |
| function getUserLimits() { return _userLimits; } | |
| function cachePlanData(plan, limits) { | |
| _userPlan = plan; | |
| _userLimits = limits; | |
| localStorage.setItem("user_plan", plan); | |
| if (limits) localStorage.setItem("user_limits", JSON.stringify(limits)); | |
| } | |
| function canAccessFeature(feature) { | |
| if (isProUser()) return true; | |
| // Free plan has limited access | |
| const freeFeatures = { repo_scan: true, basic_scan: true }; | |
| return !!freeFeatures[feature]; | |
| } | |
| // ---- RETRY HELPER ---- | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| // ---- CORE REQUEST ---- | |
| async function apiRequest(endpoint, options = {}, retries = MAX_RETRIES) { | |
| const token = getToken(); | |
| const apiKey = getApiKey(); | |
| // FIX: only attach x-api-key when the caller explicitly opts in via options.useApiKey | |
| // Sending a stale/rotated api_key on every browser request caused 403 "Invalid API key" | |
| // which was previously misclassified as a PlanError, breaking all dashboard calls. | |
| const sendApiKey = options.useApiKey && apiKey && apiKey !== "undefined" && apiKey !== "null"; | |
| const headers = { | |
| "Content-Type": "application/json", | |
| ...(token && token !== "undefined" && token !== "null" && { "Authorization": `Bearer ${token}` }), | |
| ...(sendApiKey && { "x-api-key": apiKey }), | |
| ...(options.headers || {}) | |
| }; | |
| // Strip internal flag before passing to fetch | |
| const { useApiKey: _omit, ...fetchOptions } = options; | |
| try { | |
| const res = await fetch(BASE_URL + endpoint, { ...fetchOptions, headers }); | |
| if (res.status === 401) { | |
| clearAuth(); | |
| throw new Error("Session expired. Please log in again."); | |
| } | |
| if (res.status === 403) { | |
| const body = await res.json().catch(() => ({})); | |
| const msg = body?.detail?.error || body?.error || "Access denied"; | |
| // FIX: "Invalid API key" is an auth/credential error, NOT a plan restriction. | |
| // Strip the bad key from storage and retry the request without it so the | |
| // JWT-only flow takes over β no upgrade modal, no crash. | |
| if (msg === "Invalid API key") { | |
| console.warn("[SafeAIScan] Stale API key detected β removing from storage and retrying."); | |
| localStorage.removeItem("api_key"); | |
| _userLimits = null; | |
| localStorage.removeItem("user_limits"); | |
| // Retry once without the api key | |
| if (retries > 0) { | |
| return apiRequest(endpoint, { ...fetchOptions, headers: options.headers }, retries - 1); | |
| } | |
| throw new Error("API key invalid. Please rotate your key in settings."); | |
| } | |
| throw new PlanError(msg); | |
| } | |
| if (res.status === 429) { | |
| const body = await res.json().catch(() => ({})); | |
| const msg = body?.detail?.error || body?.error || "Usage limit reached"; | |
| throw new LimitError(msg); | |
| } | |
| if (!res.ok) { | |
| const body = await res.json().catch(() => null); | |
| const msg = body?.detail?.error || body?.error || res.statusText || `HTTP ${res.status}`; | |
| throw new Error(msg); | |
| } | |
| return res; | |
| } catch (err) { | |
| if (err instanceof PlanError || err instanceof LimitError) throw err; | |
| if (err.message.includes("Session expired")) throw err; | |
| if (err.message.includes("API key invalid")) throw err; | |
| if (retries > 0) { | |
| await sleep(RETRY_DELAY_MS); | |
| return apiRequest(endpoint, fetchOptions, retries - 1); | |
| } | |
| throw err; | |
| } | |
| } | |
| // ---- CUSTOM ERROR TYPES ---- | |
| class PlanError extends Error { | |
| constructor(message) { | |
| super(message); | |
| this.name = "PlanError"; | |
| } | |
| } | |
| class LimitError extends Error { | |
| constructor(message) { | |
| super(message); | |
| this.name = "LimitError"; | |
| } | |
| } | |
| // ---- SAFE JSON PARSE ---- | |
| async function safeJson(res) { | |
| const text = await res.text(); | |
| try { | |
| const parsed = JSON.parse(text); | |
| // Unwrap standardized {success, data} envelope | |
| if (parsed && typeof parsed === "object" && "success" in parsed) { | |
| if (!parsed.success) throw new Error(parsed.error || "Request failed"); | |
| return parsed.data ?? parsed; | |
| } | |
| return parsed; | |
| } catch (e) { | |
| if (e.message !== "Request failed") { | |
| console.error("Non-JSON response:", text.substring(0, 200)); | |
| } | |
| throw e; | |
| } | |
| } | |
| // ============================================================ | |
| // API METHODS | |
| // ============================================================ | |
| async function analyzeCode(text) { | |
| const res = await apiRequest("/api/analyze", { | |
| method: "POST", | |
| body: JSON.stringify({ text }) | |
| }); | |
| const data = await safeJson(res); | |
| // Cache plan data from response for usage counters | |
| if (data.plan && data.usage_limit) { | |
| cachePlanData(data.plan, null); | |
| document.dispatchEvent(new CustomEvent("planUpdated", { detail: data })); | |
| } | |
| return data; | |
| } | |
| async function scanRepoAPI(repoUrl) { | |
| if (!canAccessFeature("repo_scan")) { | |
| throw new PlanError("Repo scanning requires Pro or Enterprise. Upgrade to unlock."); | |
| } | |
| const res = await apiRequest("/api/scan-repo", { | |
| method: "POST", | |
| body: JSON.stringify({ repo_url: repoUrl }) | |
| }); | |
| return safeJson(res); | |
| } | |
| async function getTaskStatus(taskId) { | |
| const res = await apiRequest(`/api/task/${taskId}`); | |
| return safeJson(res); | |
| } | |
| async function getUsage() { | |
| const res = await apiRequest("/api/usage"); | |
| return safeJson(res); | |
| } | |
| async function getHistory() { | |
| const res = await apiRequest("/api/history"); | |
| return safeJson(res); | |
| } | |
| async function getMe() { | |
| // Backend route: GET /api/me β returns full subscription + trial info | |
| try { | |
| const res = await apiRequest("/api/me"); | |
| const data = await safeJson(res); | |
| if (data?.plan) localStorage.setItem("user_plan", data.plan); | |
| if (data?.is_pro !== undefined) localStorage.setItem("is_pro", data.is_pro ? "true" : "false"); | |
| if (data?.trial_active !== undefined) localStorage.setItem("trial_active", data.trial_active ? "true" : "false"); | |
| if (data?.days_left !== undefined) localStorage.setItem("trial_days_left", String(data.days_left)); | |
| cachePlanData(data.plan, data.limits || null); | |
| return data; | |
| } catch (err) { | |
| if (err.message.includes("404")) { | |
| return { | |
| plan: localStorage.getItem("user_plan") || "free", | |
| is_pro: localStorage.getItem("is_pro") === "true", | |
| trial_active: localStorage.getItem("trial_active") === "true", | |
| days_left: parseInt(localStorage.getItem("trial_days_left") || "0"), | |
| user_id: localStorage.getItem("user_id") || "", | |
| email: localStorage.getItem("user_email") || "" | |
| }; | |
| } | |
| throw err; | |
| } | |
| } | |
| async function getTrialStatus() { | |
| try { | |
| const res = await apiRequest("/api/trial/status"); | |
| return safeJson(res); | |
| } catch (err) { | |
| console.warn("[SecretScan] trial status unavailable:", err.message); | |
| return { | |
| plan: localStorage.getItem("user_plan") || "free", | |
| is_pro: localStorage.getItem("is_pro") === "true", | |
| trial_active: localStorage.getItem("trial_active") === "true", | |
| days_left: parseInt(localStorage.getItem("trial_days_left") || "0"), | |
| trial_expired: false, days_left: 0 | |
| }; | |
| } | |
| } | |
| function rotateApiKey() { | |
| // FIX: key rotation now calls the backend, then refreshes UI via initApiKey if available | |
| apiRequest("/api/auth/rotate-key", { method: "POST" }) | |
| .then(res => safeJson(res)) | |
| .then(data => { | |
| const newKey = data?.api_key || data?.data?.api_key; | |
| if (newKey) { | |
| localStorage.setItem("api_key", newKey); | |
| // initApiKey lives in app.js β call only if loaded | |
| if (typeof window.initApiKey === "function") window.initApiKey(); | |
| } | |
| showToast("API key rotated successfully", "success"); | |
| }) | |
| .catch(err => showToast("Key rotation failed: " + err.message, "error")); | |
| } | |
| async function fetchCVE() { | |
| const input = document.getElementById("cveInput")?.value?.trim(); | |
| if (!input) return showToast("Enter a CVE ID or keyword", "warning"); | |
| const panel = document.getElementById("cvePanel"); | |
| if (!panel) return; | |
| panel.innerHTML = `<div class="skeleton" style="height:60px;border-radius:8px;"></div>`; | |
| try { | |
| const res = await apiRequest(`/api/cve/search?query=${encodeURIComponent(input)}`); | |
| const data = await safeJson(res); | |
| const cves = data?.cves || data?.data?.cves || []; | |
| if (!cves.length) { | |
| panel.innerHTML = `<div style="color:var(--text-muted);font-size:12px;padding:8px 0;">No CVEs found for "<strong>${escHtml(input)}</strong>"</div>`; | |
| return; | |
| } | |
| panel.innerHTML = cves.map(cve => ` | |
| <div class="panel mb-2 pop-in"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;"> | |
| <strong style="font-size:12px;color:var(--accent-2);">${escHtml(cve.id || "")}</strong> | |
| <span class="badge-pill sev-${cvssSev(cve.cvss)}">${cve.cvss ?? "N/A"}</span> | |
| </div> | |
| <div style="font-size:11px;color:var(--text-muted);margin-top:4px;">${escHtml((cve.description || "").substring(0, 180))}</div> | |
| </div> | |
| `).join(""); | |
| } catch (err) { | |
| console.error(err); | |
| panel.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px 0;"> | |
| <i class="bi bi-exclamation-triangle me-1"></i>${escHtml(err.message)} | |
| </div>`; | |
| } | |
| } | |
| // FIX: guard with typeof check β app.js and minisky.js also define these helpers | |
| if (typeof window.cvssSev === "undefined") { | |
| window.cvssSev = function cvssSev(score) { | |
| if (score == null) return "low"; | |
| if (score >= 9) return "critical"; | |
| if (score >= 7) return "high"; | |
| if (score >= 4) return "medium"; | |
| return "low"; | |
| }; | |
| } | |
| function cvssSev(score) { return window.cvssSev(score); } | |
| if (typeof window.escHtml === "undefined") { | |
| window.escHtml = function escHtml(str) { | |
| if (!str) return ""; | |
| return String(str) | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| }; | |
| } | |
| function escHtml(str) { return window.escHtml(str); } | |
| // ============================================================ | |
| // UPGRADE PROMPT (shown on PlanError / LimitError) | |
| // ============================================================ | |
| function showUpgradePrompt(message) { | |
| // Remove any existing upgrade modal | |
| document.getElementById("_upgradeModal")?.remove(); | |
| const plan = localStorage.getItem("user_plan") || "free"; | |
| const alreadyTrial = plan === "pro_trial"; | |
| const alreadyPro = plan === "pro" || plan === "enterprise"; | |
| // Show trial offer to free users who haven't tried it yet | |
| const offerTrial = !alreadyTrial && !alreadyPro; | |
| const modal = document.createElement("div"); | |
| modal.id = "_upgradeModal"; | |
| modal.setAttribute("style", | |
| "position:fixed;inset:0;z-index:99998;display:flex;align-items:center;" + | |
| "justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(6px);padding:20px;" | |
| ); | |
| const featureList = offerTrial | |
| ? ["Unlimited scans every day", | |
| "All findings revealed", | |
| "GitHub repo scanning", | |
| "Deep AI analysis & fix suggestions", | |
| "PDF report download", | |
| "CVE enrichment & lookup"] | |
| .map(f => '<div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-muted);">' + | |
| '<i class="bi bi-check-circle-fill" style="color:#00ffa3;font-size:11px;flex-shrink:0;"></i>' + f + '</div>') | |
| .join("") | |
| : ""; | |
| const headerHTML = offerTrial | |
| ? '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">' + | |
| '<span style="font-size:22px;">π</span>' + | |
| '<div style="font-family:Syne,sans-serif;font-size:18px;font-weight:800;">Try Pro Free β 30 Days</div>' + | |
| '</div>' | |
| : '<div style="font-family:Syne,sans-serif;font-size:18px;font-weight:800;margin-bottom:6px;">' + | |
| '<i class="bi bi-lightning-charge" style="color:var(--warning);"></i> ' + | |
| (alreadyTrial ? "Keep Your Pro Access" : "Upgrade to Pro") + | |
| '</div>'; | |
| const featureBlock = featureList | |
| ? '<div style="background:rgba(0,255,163,.05);border:1px solid rgba(0,255,163,.15);' + | |
| 'border-radius:12px;padding:14px;margin-bottom:16px;display:flex;flex-direction:column;gap:6px;">' + | |
| featureList + '</div>' | |
| : ""; | |
| const primaryBtn = offerTrial | |
| ? '<a href="checkout.html" style="display:block;text-align:center;' + | |
| 'background:linear-gradient(135deg,#00ffa3,#5b7bfe);color:#0f172a;' + | |
| 'padding:13px;border-radius:10px;font-size:14px;font-weight:700;text-decoration:none;' + | |
| 'box-shadow:0 6px 20px rgba(0,255,163,.3);">' + | |
| '<i class="bi bi-gift-fill me-1"></i>Start Free 30-Day Trial β No Card Needed</a>' + | |
| '<div style="text-align:center;font-size:11px;color:var(--text-faint);margin-top:4px;">After trial: $1.99/mo Β· Cancel anytime</div>' | |
| : '<button onclick="window.location.href=\'checkout.html\'" style="' + | |
| 'background:linear-gradient(135deg,#5b7bfe,#4361ee);color:#fff;border:none;' + | |
| 'padding:12px 20px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;width:100%;">' + | |
| '<i class="bi bi-lightning-charge me-1"></i>Upgrade to Pro β $1.99/mo</button>'; | |
| modal.innerHTML = | |
| '<div style="background:var(--bg-2);border:1px solid var(--border-bright);border-radius:18px;' + | |
| 'padding:28px 28px;max-width:420px;width:100%;box-shadow:0 24px 80px rgba(0,0,0,0.6);' + | |
| 'animation:popIn 0.25s cubic-bezier(.34,1.56,.64,1) both;">' + | |
| headerHTML + | |
| '<p style="font-size:13px;color:var(--text-muted);margin-bottom:14px;line-height:1.55;">' + escHtml(message) + '</p>' + | |
| featureBlock + | |
| '<div style="display:grid;gap:8px;">' + | |
| primaryBtn + | |
| '<button onclick="document.getElementById("_upgradeModal").remove()" style="' + | |
| 'background:transparent;color:var(--text-faint);border:none;padding:8px;font-size:12px;cursor:pointer;">' + | |
| 'Maybe Later</button>' + | |
| '</div></div>'; | |
| document.body.appendChild(modal); | |
| modal.addEventListener("click", e => { if (e.target === modal) modal.remove(); }); | |
| } | |
| // ============================================================ | |
| // TOAST NOTIFICATIONS | |
| // FIX: guarded β app.js also defines showToast; only define once | |
| // ============================================================ | |
| if (typeof window.showToast === "undefined") | |
| function showToast(message, type = "info") { | |
| const colors = { | |
| info: "rgba(91,123,254,0.15)", | |
| success: "rgba(52,211,153,0.12)", | |
| warning: "rgba(251,146,60,0.12)", | |
| error: "rgba(244,63,94,0.12)" | |
| }; | |
| const textColors = { | |
| info: "#93aaff", success: "#6ee7b7", warning: "#fdba74", error: "#fb7185" | |
| }; | |
| const icons = { | |
| info: "bi-info-circle", success: "bi-check-circle", warning: "bi-exclamation-triangle", error: "bi-x-circle" | |
| }; | |
| if (!document.querySelector("#toastStyle")) { | |
| const s = document.createElement("style"); | |
| s.id = "toastStyle"; | |
| s.textContent = ` | |
| @keyframes toastIn { from { opacity:0; transform:translateY(12px) scale(0.95); } to { opacity:1; transform:translateY(0) scale(1); } } | |
| @keyframes toastOut { from { opacity:1; transform:scale(1); } to { opacity:0; transform:translateY(6px) scale(0.95); } } | |
| .toast-container { position:fixed;bottom:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;align-items:flex-end; } | |
| `; | |
| document.head.appendChild(s); | |
| } | |
| let container = document.querySelector(".toast-container"); | |
| if (!container) { | |
| container = document.createElement("div"); | |
| container.className = "toast-container"; | |
| document.body.appendChild(container); | |
| } | |
| const toast = document.createElement("div"); | |
| toast.style.cssText = ` | |
| background:${colors[type] || colors.info}; | |
| color:${textColors[type] || textColors.info}; | |
| border:1px solid ${textColors[type] || textColors.info}33; | |
| border-radius:10px;padding:11px 16px;font-size:13px; | |
| font-family:'DM Sans',sans-serif;font-weight:500; | |
| backdrop-filter:blur(12px); | |
| box-shadow:0 8px 32px rgba(0,0,0,0.4); | |
| animation:toastIn 0.25s ease both; | |
| max-width:320px;display:flex;align-items:center;gap:8px;cursor:pointer; | |
| `; | |
| toast.innerHTML = `<i class="bi ${icons[type] || icons.info}" style="flex-shrink:0;font-size:14px;"></i><span>${escHtml(message)}</span>`; | |
| toast.addEventListener("click", () => dismiss()); | |
| container.appendChild(toast); | |
| const dismiss = () => { | |
| toast.style.animation = "toastOut 0.22s ease forwards"; | |
| setTimeout(() => toast.remove(), 230); | |
| }; | |
| setTimeout(dismiss, 3800); | |
| } | |
| // Global plan-error handler | |
| window.addEventListener("unhandledrejection", e => { | |
| if (e.reason instanceof PlanError) { | |
| showUpgradePrompt(e.reason.message); | |
| e.preventDefault(); | |
| } else if (e.reason instanceof LimitError) { | |
| showToast(e.reason.message, "warning"); | |
| e.preventDefault(); | |
| } else { | |
| console.error("Unhandled rejection:", e.reason); | |
| } | |
| }); | |
| // Expose for dashboard | |
| window.PlanError = PlanError; | |
| window.LimitError = LimitError; | |
| window.showUpgradePrompt = showUpgradePrompt; | |