File size: 9,256 Bytes
b1c84b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e55328
b1c84b5
 
 
c78c2c1
b1c84b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e55328
 
b1c84b5
7e55328
 
c78c2c1
b1c84b5
 
 
 
 
 
 
7e55328
 
 
b1c84b5
 
 
7e55328
 
c78c2c1
 
7e55328
b1c84b5
7e55328
b1c84b5
7e55328
b1c84b5
c78c2c1
b1c84b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c78c2c1
 
b1c84b5
7e55328
b1c84b5
7e55328
b1c84b5
c78c2c1
b1c84b5
 
 
 
 
 
 
 
 
0b3f171
 
 
 
 
 
 
 
 
7e55328
0b3f171
 
 
 
 
 
 
 
 
 
 
 
 
4cb413f
 
 
 
 
 
 
b1c84b5
 
 
 
 
 
7e55328
 
b1c84b5
 
 
 
 
 
 
 
 
7e55328
b1c84b5
 
 
0b3f171
 
 
 
 
 
7e55328
0b3f171
 
 
b1c84b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c78c2c1
 
 
b1c84b5
 
 
 
c78c2c1
 
 
 
 
 
 
 
b1c84b5
 
 
 
c78c2c1
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * PhilVerify β€” Background Service Worker (Manifest V3)
 *
 * Responsibilities:
 *  - Proxy API calls to the PhilVerify FastAPI backend
 *  - File-based cache via chrome.storage.local (24-hour TTL, max 50 entries)
 *  - Maintain personal verification history
 *  - Respond to messages from content.js and popup.js
 *
 * Message types handled:
 *  VERIFY_TEXT  { text }        β†’ VerificationResponse
 *  VERIFY_URL   { url }         β†’ VerificationResponse
 *  GET_HISTORY  {}              β†’ { history: HistoryEntry[] }
 *  GET_SETTINGS {}              β†’ { apiBase, autoScan }
 *  SAVE_SETTINGS { apiBase, autoScan } β†’ {}
 */

const CACHE_TTL_MS = 24 * 60 * 60 * 1000   // 24 hours
const MAX_HISTORY = 50

// ── Default settings ──────────────────────────────────────────────────────────
const DEFAULT_SETTINGS = {
  apiBase: 'http://localhost:8000/api',
  autoScan: true,    // Automatically scan Facebook feed posts
}

// ── Utilities ─────────────────────────────────────────────────────────────────
/** Validate that a string is a safe http/https URL */
function isHttpUrl(str) {
  if (!str || typeof str !== 'string') return false
  try {
    const u = new URL(str)
    return u.protocol === 'http:' || u.protocol === 'https:'
  } catch { return false }
}
async function sha256prefix(text, len = 16) {
  const buf = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(text.trim().toLowerCase()),
  )
  return Array.from(new Uint8Array(buf))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
    .slice(0, len)
}

async function getSettings() {
  const stored = await chrome.storage.local.get('settings')
  return { ...DEFAULT_SETTINGS, ...(stored.settings ?? {}) }
}

// ── Cache helpers ─────────────────────────────────────────────────────────────

async function getCached(key) {
  const stored = await chrome.storage.local.get(key)
  const entry = stored[key]
  if (!entry) return null
  if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
    await chrome.storage.local.remove(key)
    return null
  }
  return entry.result
}

async function setCached(key, result, preview) {
  await chrome.storage.local.set({
    [key]: { result, timestamp: Date.now() },
  })

  // Prepend to history list
  const { history = [] } = await chrome.storage.local.get('history')
  const entry = {
    id: key,
    timestamp: new Date().toISOString(),
    text_preview: preview.slice(0, 80),
    verdict: result.verdict,
    final_score: result.final_score,
    model_tier: result.layer1?.model_tier ?? null,
  }
  const updated = [entry, ...history.filter(h => h.id !== key)].slice(0, MAX_HISTORY)
  await chrome.storage.local.set({ history: updated })
}

// ── API calls ─────────────────────────────────────────────────────────────────

async function verifyText(text, imageUrl) {
  const key = 'txt_' + await sha256prefix(text)
  const hit = await getCached(key)
  if (hit) return { ...hit, _fromCache: true }

  const { apiBase } = await getSettings()
  const payload = { text }
  if (imageUrl && isHttpUrl(imageUrl)) payload.image_url = imageUrl
  
  console.log('[PhilVerify BG] Calling API:', `${apiBase}/verify/text`, payload)

  const res = await fetch(`${apiBase}/verify/text`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  console.log('[PhilVerify BG] API Response Status:', res.status)
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    throw new Error(body.detail ?? `API error ${res.status}`)
  }
  const result = await res.json()
  await setCached(key, result, text)
  return result
}

async function verifyUrl(url) {
  const key = 'url_' + await sha256prefix(url)
  const hit = await getCached(key)
  if (hit) return { ...hit, _fromCache: true }

  const { apiBase } = await getSettings()
  console.log('[PhilVerify BG] Calling API:', `${apiBase}/verify/url`, url)

  const res = await fetch(`${apiBase}/verify/url`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ url }),
  })
  console.log('[PhilVerify BG] API Response Status:', res.status)
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    throw new Error(body.detail ?? `API error ${res.status}`)
  }
  const result = await res.json()
  await setCached(key, result, url)
  return result
}

async function verifyImageUrl(imageUrl) {
  const key = 'img_' + await sha256prefix(imageUrl)
  const hit = await getCached(key)
  if (hit) return { ...hit, _fromCache: true }

  const { apiBase } = await getSettings()
  const imgRes = await fetch(imageUrl)
  if (!imgRes.ok) throw new Error(`Could not fetch image: ${imgRes.status}`)
  const blob = await imgRes.blob()
  const ct = imgRes.headers.get('content-type') ?? 'image/jpeg'
  const ext = ct.includes('png') ? 'png' : ct.includes('webp') ? 'webp' : 'jpg'
  const formData = new FormData()
  formData.append('file', blob, `image.${ext}`)
  const res = await fetch(`${apiBase}/verify/image`, { method: 'POST', body: formData })
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    throw new Error(body.detail ?? `API error ${res.status}`)
  }
  const result = await res.json()
  await setCached(key, result, imageUrl)
  return result
}

// ── Side panel ────────────────────────────────────────────────────────────────

// Open the side panel when the toolbar icon is clicked (stays open while browsing)
chrome.action.onClicked.addListener((tab) => {
  chrome.sidePanel.open({ windowId: tab.windowId })
})

// ── Message handler ───────────────────────────────────────────────────────────

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  switch (msg.type) {

    case 'VERIFY_TEXT':
      verifyText(msg.text, msg.imageUrl)
        .then(r => sendResponse({ ok: true, result: r }))
        .catch(e => sendResponse({ ok: false, error: e.message }))
      return true   // keep message channel open for async response

    case 'VERIFY_URL':
      if (!isHttpUrl(msg.url)) {
        sendResponse({ ok: false, error: 'Invalid URL: only http/https allowed' })
        return false
      }
      verifyUrl(msg.url)
        .then(r => sendResponse({ ok: true, result: r }))
        .catch(e => sendResponse({ ok: false, error: e.message }))
      return true

    case 'VERIFY_IMAGE_URL':
      if (!isHttpUrl(msg.imageUrl)) {
        sendResponse({ ok: false, error: 'Invalid image URL' })
        return false
      }
      verifyImageUrl(msg.imageUrl)
        .then(r => sendResponse({ ok: true, result: r }))
        .catch(e => sendResponse({ ok: false, error: e.message }))
      return true

    case 'GET_HISTORY':
      chrome.storage.local.get('history')
        .then(({ history = [] }) => sendResponse({ history }))
      return true

    case 'GET_SETTINGS':
      getSettings().then(s => sendResponse(s))
      return true

    case 'SAVE_SETTINGS': {
      const incoming = msg.settings ?? {}
      // Validate apiBase is a safe URL before persisting
      if (incoming.apiBase && !isHttpUrl(incoming.apiBase)) {
        sendResponse({ ok: false, error: 'Invalid API URL: only http/https allowed' })
        return false
      }
      // Merge with existing settings so a partial update doesn't clobber other fields
      getSettings()
        .then(current => chrome.storage.local.set({ settings: { ...current, ...incoming } }))
        .then(() => sendResponse({ ok: true }))
      return true
    }

    case 'CHECK_HEALTH': {
      getSettings()
        .then(({ apiBase }) => fetch(`${apiBase}/health`, { signal: AbortSignal.timeout(3000) }))
        .then(res => sendResponse({ ok: res.ok, status: res.status }))
        .catch(e => sendResponse({ ok: false, error: e.message }))
      return true
    }

    default:
      break
  }
})

// ── SPA navigation: re-scan Facebook posts after pushState navigation ─────────
// Facebook is a single-page app β€” clicking Home/Profile/etc. does a pushState
// navigation without reloading the page. The content script stays alive but
// needs to re-scan for new post articles after the page content changes.
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
  if (details.url.includes('facebook.com')) {
    chrome.tabs.sendMessage(details.tabId, { action: 'RE_SCAN_POSTS' }, () => {
      // Suppress "no listener" errors when the content script isn't loaded yet
      if (chrome.runtime.lastError) {}
    })
  }
})