'use strict'; // Cross-browser API namespace. Firefox provides Promise-based `browser.*`; // Chrome MV3 exposes `browser.*` too, but some builds only populate the // `chrome.*` global. Alias to whichever is present. if (typeof browser === 'undefined' || browser == null) { var browser = (typeof chrome !== 'undefined' ? chrome : {}); } function errMsg(err) { return String((err && err.message) || err); } browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg && msg.type === 'encrypt-password') { encryptPassword(String(msg.password || '')) .then((cipher) => sendResponse({ ok: true, cipher })) .catch((err) => sendResponse({ ok: false, error: errMsg(err) })); return true; } if (msg && msg.type === 'decrypt-password') { decryptPassword(msg.cipher) .then((password) => sendResponse({ ok: true, password })) .catch((err) => sendResponse({ ok: false, error: errMsg(err) })); return true; } if (msg && msg.type === 'export') { runExport(msg.format, msg.data) .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ ok: false, error: errMsg(err) })); return true; } if (msg && msg.type === 'generate-pdf') { generatePdf(msg.data) .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ ok: false, error: errMsg(err) })); return true; } }); // ---- Password encryption (at-rest) ---- // AES-GCM with a random 256-bit key. The key is stored in browser.storage.local // so it survives browser/service-worker restarts (otherwise previously-encrypted // passwords could never be decrypted again). This removes the plaintext-password // smell from storage — it is NOT strong protection against an attacker who can // read both the storage and the extension source (the user's threat model is // only the school portal, no third-party exfiltration). const CRYPTO_KEY_STORE = 'ismisCryptoKey'; async function getCryptoKey() { const { [CRYPTO_KEY_STORE]: stored } = await browser.storage.local.get(CRYPTO_KEY_STORE); if (stored) { return crypto.subtle.importKey('raw', new Uint8Array(stored), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); } const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const raw = await crypto.subtle.exportKey('raw', key); await browser.storage.local.set({ [CRYPTO_KEY_STORE]: Array.from(new Uint8Array(raw)) }); return key; } async function encryptPassword(password) { const key = await getCryptoKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const data = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(password)); return { iv: Array.from(iv), data: Array.from(new Uint8Array(data)) }; } async function decryptPassword(cipher) { if (!cipher || !cipher.iv || !cipher.data) throw new Error('Invalid encrypted password'); const key = await getCryptoKey(); const pt = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: new Uint8Array(cipher.iv) }, key, new Uint8Array(cipher.data)); return new TextDecoder().decode(pt); } // Generic downloader used by every export format. `filename` is used with // saveAs so the user picks the location, mirroring the PDF flow. async function downloadBytes(bytes, mime, filename, fallback) { const safeFilename = filename || fallback; const blob = new Blob([bytes], { type: mime }); const url = (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') ? URL.createObjectURL(blob) : null; let downloadUrl = url; if (!downloadUrl) { let binary = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } downloadUrl = 'data:' + mime + ';base64,' + btoa(binary); } try { const id = await browser.downloads.download({ url: downloadUrl, filename: safeFilename, saveAs: true }); const onChanged = (delta) => { if (delta.id === id && delta.state && (delta.state.current === 'complete' || delta.state.current === 'interrupted')) { browser.downloads.onChanged.removeListener(onChanged); } }; browser.downloads.onChanged.addListener(onChanged); } catch (err) { if (url) { try { URL.revokeObjectURL(url); } catch (e) {} } throw err; } } async function runExport(format, data) { const header = data.header || {}; const year = parseInt(data.year, 10); const period = formatPeriod(data.period || header.semester || ''); const compactProgram = ISMIStools.compactProgram(header.program || ''); const schoolYear = Number.isFinite(year) ? year + '-' + (year + 1) : ''; const core = [ header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''), compactProgram, schoolYear ? 'S.Y. ' + schoolYear : '', period ].filter(Boolean); const base = core.join(' - ') || 'Study Load'; if (format === 'csv') { const text = ISMIStools.buildStudyLoadCsv(data); const bytes = new TextEncoder().encode(text); await downloadBytes(bytes, 'text/csv', base + '.csv', 'Study Load.csv'); } else if (format === 'json') { const text = JSON.stringify(ISMIStools.buildStudyLoadJson(data), null, 2); const bytes = new TextEncoder().encode(text); await downloadBytes(bytes, 'application/json', base + '.json', 'Study Load.json'); } else if (format === 'xlsx') { const bytes = ISMIStools.buildStudyLoadXlsx(data); await downloadBytes(bytes, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base + '.xlsx', 'Study Load.xlsx'); } else if (format === 'nextup') { const text = ISMIStools.buildNextUpCsv(data); const bytes = new TextEncoder().encode(text); await downloadBytes(bytes, 'text/csv', base + '-nextup.csv', 'Study Load - nextup.csv'); } else { throw new Error('Unknown export format: ' + format); } } function formatPeriod(period) { if (!period) return ''; return String(period) .replace(/\b1ST\b/g, '1st') .replace(/\b2ND\b/g, '2nd') .replace(/\b3RD\b/g, '3rd') .replace(/\b(\d+)TH\b/g, '$1th') .toLowerCase() .replace(/^./, (c) => c.toUpperCase()); } async function generatePdf(data) { const bytes = await ISMIStools.buildStudyLoadPdf(data); const header = data.header || {}; const year = parseInt(data.year, 10); const period = formatPeriod(data.period || header.semester || ''); const compactProgram = ISMIStools.compactProgram(header.program || ''); const schoolYear = Number.isFinite(year) ? `${year}-${year + 1}` : ''; const parts = [ header.studentId || (header.fullName ? header.fullName.replace(/\s+/g, ' ') : ''), compactProgram, schoolYear ? 'S.Y. ' + schoolYear : '', period, 'Study Load' ].filter(Boolean); const filename = parts.join(' - ') + '.pdf'; const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf'; await downloadBytes(bytes, 'application/pdf', filename, fallback); }