File size: 18,646 Bytes
3dcd523
9243c67
 
3dcd523
6d5877c
3dcd523
 
afff9e2
239f5e4
3dcd523
 
 
239f5e4
 
 
 
 
 
9243c67
 
 
a41c679
9243c67
 
a41c679
 
9243c67
afff9e2
54c2be7
9243c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afff9e2
 
 
9243c67
 
 
 
afff9e2
 
3dcd523
 
 
 
 
 
 
 
20a178a
 
 
 
 
3dcd523
 
20a178a
 
3dcd523
 
 
20a178a
 
 
3dcd523
20a178a
3dcd523
 
 
 
a405dc1
6d5877c
afff9e2
 
20a178a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afff9e2
 
 
 
 
20a178a
afff9e2
 
 
3dcd523
afff9e2
20a178a
afff9e2
3dcd523
239f5e4
3dcd523
 
 
afff9e2
 
20a178a
afff9e2
 
3dcd523
20a178a
3dcd523
 
 
6d5877c
 
afff9e2
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcd523
8ef8aee
 
 
afff9e2
 
 
 
 
 
 
 
 
 
 
 
8ef8aee
 
 
3dcd523
 
 
 
239f5e4
 
 
 
6d5877c
9243c67
a41c679
 
9243c67
 
54c2be7
9243c67
6d5877c
 
bd79be1
9243c67
 
 
 
bd79be1
54c2be7
bd79be1
 
 
 
 
9243c67
bd79be1
09d395a
 
 
9243c67
 
09d395a
 
 
9243c67
54c2be7
09d395a
 
 
9243c67
 
 
 
a41c679
 
 
 
 
9243c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb3062d
afff9e2
 
6af1b6e
9243c67
 
 
 
 
 
 
 
 
 
 
 
 
a590c3d
 
 
9243c67
 
 
a590c3d
9243c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afff9e2
a590c3d
3dcd523
 
ce28664
 
 
 
 
 
 
 
 
3dcd523
ce28664
 
 
 
 
 
 
 
 
 
 
afff9e2
ce28664
afff9e2
3dcd523
afff9e2
 
 
a5e37dd
 
 
 
 
 
 
 
 
afff9e2
a5e37dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
afff9e2
 
 
 
 
 
ce28664
3dcd523
ce28664
3dcd523
 
 
 
 
 
 
 
 
 
afff9e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcd523
 
 
 
 
 
afff9e2
 
3dcd523
 
afff9e2
 
3dcd523
afff9e2
 
3dcd523
afff9e2
3dcd523
afff9e2
 
 
 
 
3dcd523
 
afff9e2
3dcd523
afff9e2
 
 
 
 
 
 
 
 
3dcd523
afff9e2
 
 
 
 
a5e37dd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
// ============================================================
//  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, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;");
  };
}
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(&quot;_upgradeModal&quot;).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;