feat(v1.4.0): exports + smart year level + progress + encrypted passwords
v1.4.0: CSV/Excel/JSON/NextUp exports, units-first smart year standing, live export progress bar, AES-GCM at-rest password encryption.
This commit was merged in pull request #4.
This commit is contained in:
+120
-37
@@ -10,28 +10,25 @@ function errMsg(err) {
|
||||
return String((err && err.message) || err);
|
||||
}
|
||||
|
||||
// Build a download URL from PDF bytes. Firefox's background is a real page
|
||||
// (URL.createObjectURL available) and supports blob: URLs in downloads.
|
||||
// Chrome MV3 runs the background as a service worker where URL.createObjectURL
|
||||
// is NOT available, but its downloads API accepts base64 data: URLs.
|
||||
// Firefox rejects data: URLs in browser.downloads.download with
|
||||
// "Access denied for URL data:...", so pick per-browser.
|
||||
function buildDownloadUrl(bytes) {
|
||||
if (typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function') {
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
// Fallback: Chrome MV3 service worker. PDFs here are small (~150KB), well
|
||||
// within data-URL length limits.
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return 'data:application/pdf;base64,' + btoa(binary);
|
||||
}
|
||||
|
||||
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 }))
|
||||
@@ -40,6 +37,107 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 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)
|
||||
@@ -53,9 +151,6 @@ function formatPeriod(period) {
|
||||
|
||||
async function generatePdf(data) {
|
||||
const bytes = await ISMIStools.buildStudyLoadPdf(data);
|
||||
// Firefox accepts blob: object URLs; Chrome MV3 service worker has no
|
||||
// URL.createObjectURL, so it falls back to a base64 data: URL there.
|
||||
const url = buildDownloadUrl(bytes);
|
||||
const header = data.header || {};
|
||||
const year = parseInt(data.year, 10);
|
||||
const period = formatPeriod(data.period || header.semester || '');
|
||||
@@ -70,17 +165,5 @@ async function generatePdf(data) {
|
||||
].filter(Boolean);
|
||||
const filename = parts.join(' - ') + '.pdf';
|
||||
const fallback = 'Study Load - ' + (header.fullName || 'Student') + '.pdf';
|
||||
const safeFilename = filename || fallback;
|
||||
|
||||
try {
|
||||
const id = await browser.downloads.download({ url, 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) {
|
||||
throw err;
|
||||
}
|
||||
await downloadBytes(bytes, 'application/pdf', filename, fallback);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user