import browser from "webextension-polyfill"; import isEqual from "lodash/isEqual"; /** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; /** * Client-only: last known remote `updated_at` for BS+ settings (from summary or sync responses). * Never uploaded; preserved on restore; used to decide when to pull a newer cloud backup. */ export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY = "bsplus_cloud_settings_known_remote_updated_at"; /** * Written by the service worker after applying a cloud settings envelope; the SEQTA page’s * ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page). */ export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY = "bsplus_pending_theme_ensure_after_cloud"; /** * Client-only: normalized syncable storage last acked by a successful PUT. * Never uploaded; used to compute sparse upload patches. */ export const BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY = "bsplus_cloud_settings_last_uploaded_snapshot"; /** * Never uploaded to the cloud backup (OAuth and legacy keys). * IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of * `chrome.storage.local` and is never included in this payload. */ export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [ "bsplus_token", "bsplus_refresh_token", "bsplus_client_id", "bsplus_user", "cloudAccessToken", "cloudUsername", "bsplus_google_calendar", ] as const; /** * Device-only caches / school-related data: never uploaded, never applied from a * cloud snapshot (local values are kept on restore). */ export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [ "plugin.assessments-average.storage.assessments", "plugin.assessments-average.storage.weightings", ] as const; /** School-specific caches; never sync across devices. */ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = [ "plugin.global-search.storage.", "bsplus.analytics.", ] as const; const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY, "bsplus_lastCloudPoll", BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, ] as const; /** After restoring from cloud, keep local session so the user stays signed in. */ const AUTH_KEYS_TO_PRESERVE = [ "bsplus_token", "bsplus_refresh_token", "bsplus_client_id", "bsplus_user", "bsplus_google_calendar", ] as const; const OMIT_FROM_UPLOAD_EXACT = new Set([ ...KEYS_OMITTED_FROM_CLOUD_UPLOAD, ...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT, ...CLIENT_ONLY_CLOUD_KEYS_EXACT, "devMode", "devGhReleaseVersionOverride", ]); const UNSAFE_STORAGE_KEYS = new Set(["__proto__", "constructor", "prototype"]); function isUnsafeStorageKey(key: string): boolean { return UNSAFE_STORAGE_KEYS.has(key); } /** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */ export function isKeyIncludedInCloudUploadPayload(key: string): boolean { return !shouldOmitKeyFromCloudPayload(key); } function shouldOmitKeyFromCloudPayload(key: string): boolean { if (isUnsafeStorageKey(key)) return true; if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true; for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) { if (key.startsWith(prefix)) return true; } return false; } function isSensitiveDeviceKey(key: string): boolean { if ((SENSITIVE_DEVICE_STORAGE_KEYS_EXACT as readonly string[]).includes(key)) return true; for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) { if (key.startsWith(prefix)) return true; } return false; } /** Auth + device-only caches + client-only cloud metadata to keep when merging a downloaded snapshot. */ function collectLocalKeysToPreserve(local: Record): Record { const out: Record = {}; for (const k of AUTH_KEYS_TO_PRESERVE) { if (local[k] !== undefined) out[k] = local[k]; } for (const k of CLIENT_ONLY_CLOUD_KEYS_EXACT) { if (local[k] !== undefined) out[k] = local[k]; } for (const [k, v] of Object.entries(local)) { if (isSensitiveDeviceKey(k)) out[k] = v; } return out; } /** Remove keys that must never come from the server blob (defense in depth). */ function stripExcludedKeysFromRemoteData(remote: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(remote)) { if (isUnsafeStorageKey(k)) continue; if (shouldOmitKeyFromCloudPayload(k)) continue; out[k] = v; } return out; } /** Stored theme id (`selectedTheme`); trims whitespace; empty string clears. */ export function normalizeThemeIdForSync(raw: unknown): string { if (typeof raw !== "string") return ""; return raw.trim(); } /** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */ export function normalizeStorageForSync(all: Record): Record { const filtered: Record = {}; for (const [k, v] of Object.entries(all)) { if (shouldOmitKeyFromCloudPayload(k)) continue; filtered[k] = v; } return migrateLegacyToPluginSettings(filtered); } /** Keys in `current` whose values differ from `baseline` (sparse PUT body). */ export function diffSyncableStorage( current: Record, baseline: Record, ): Record { const patch: Record = {}; for (const [key, value] of Object.entries(current)) { if (!isEqual(value, baseline[key])) { patch[key] = value; } } return patch; } export type CloudSettingsUploadEnvelope = { schemaVersion: number; themeId: string; data: Record; }; /** Sparse upload envelope, or null when nothing changed vs baseline. */ export function buildUploadPatch( all: Record, baseline: Record, ): CloudSettingsUploadEnvelope | null { const normalized = normalizeStorageForSync(all); const data = diffSyncableStorage(normalized, baseline); if (Object.keys(data).length === 0) return null; return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, themeId: normalizeThemeIdForSync(all.selectedTheme), data, }; } /** Full normalized snapshot (dev export / debugging). */ export function buildUploadPayload(all: Record): CloudSettingsUploadEnvelope { const data = normalizeStorageForSync(all); return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, themeId: normalizeThemeIdForSync(all.selectedTheme), data, }; } export async function getLastUploadedSnapshot(): Promise | null> { const stored = await browser.storage.local.get(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY); const snapshot = stored[BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]; if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; return snapshot as Record; } export async function saveLastUploadedSnapshot( snapshot: Record, ): Promise { await browser.storage.local.set({ [BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]: snapshot }); } export async function clearLastUploadedSnapshot(): Promise { await browser.storage.local.remove(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY); } export async function getSnapshotForUpload(): Promise { const all = await browser.storage.local.get(); return buildUploadPayload(all as Record); } /** * Theme to ensure is installed locally after a downloaded envelope (explicit `themeId` overrides `data.selectedTheme`). * Works for any store-backed id, including **flavour (slave) variants** nested under masters in the catalogue. */ export function resolveThemeIdForPostSyncDownload(envelope: unknown): string | undefined { if (envelope && typeof envelope === "object" && "themeId" in envelope) { const top = normalizeThemeIdForSync( (envelope as Record).themeId, ); if (top) return top; } let remoteFlat: Record; if ( envelope && typeof envelope === "object" && "data" in envelope && (envelope as { data?: unknown }).data !== undefined && typeof (envelope as { data?: unknown }).data === "object" && (envelope as { data?: unknown }).data !== null && !Array.isArray((envelope as { data?: unknown }).data) ) { remoteFlat = (envelope as { data: Record }).data; } else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) { remoteFlat = envelope as Record; } else { return undefined; } const migrated = migrateLegacyToPluginSettings(remoteFlat); const fromData = normalizeThemeIdForSync(migrated.selectedTheme); return fromData === "" ? undefined : fromData; } export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise { if (!iso || typeof iso !== "string") return; await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso }); } /** * Migrate legacy storage keys to plugin settings format. * Only applies migrations for keys present in the data; does not overwrite * existing plugin settings if the legacy key is absent. */ export function migrateLegacyToPluginSettings(data: Record): Record { const result = { ...data }; function ensurePluginSettings(pluginId: string): Record { const key = `plugin.${pluginId}.settings`; if (!result[key] || typeof result[key] !== "object") { result[key] = {}; } return result[key] as Record; } // animatedbk -> plugin.animated-background.settings.enabled if ("animatedbk" in result) { const settings = ensurePluginSettings("animated-background"); if (settings.enabled === undefined) { settings.enabled = !!result.animatedbk; } delete result.animatedbk; } // bksliderinput -> plugin.animated-background.settings.speed // Legacy: string "0"-"100", New: float 0.1-2.0 if ("bksliderinput" in result) { const settings = ensurePluginSettings("animated-background"); if (settings.speed === undefined) { const legacy = parseFloat(String(result.bksliderinput)); if (!isNaN(legacy)) { settings.speed = Math.round((0.1 + (legacy / 100) * 1.9) * 100) / 100; } } delete result.bksliderinput; } // assessmentsAverage -> plugin.assessments-average.settings.enabled if ("assessmentsAverage" in result) { const settings = ensurePluginSettings("assessments-average"); if (settings.enabled === undefined) { settings.enabled = !!result.assessmentsAverage; } delete result.assessmentsAverage; } // lettergrade -> plugin.assessments-average.settings.lettergrade if ("lettergrade" in result) { const settings = ensurePluginSettings("assessments-average"); if (settings.lettergrade === undefined) { settings.lettergrade = !!result.lettergrade; } delete result.lettergrade; } // notificationCollector -> plugin.notificationCollector.settings.enabled if ("notificationCollector" in result && typeof result.notificationCollector === "boolean") { const settings = ensurePluginSettings("notificationCollector"); if (settings.enabled === undefined) { settings.enabled = result.notificationCollector; } delete result.notificationCollector; } return result; } /** * Apply the downloaded cloud snapshot by setting each key individually, * preserving auth keys and device-only sensitive caches. * Legacy keys are automatically migrated to plugin settings format. */ export async function applyDownloadedEnvelope(envelope: unknown): Promise { let remoteFlat: Record; if ( envelope && typeof envelope === "object" && "data" in envelope && (envelope as { data?: unknown }).data !== undefined && typeof (envelope as { data?: unknown }).data === "object" && (envelope as { data?: unknown }).data !== null && !Array.isArray((envelope as { data?: unknown }).data) ) { remoteFlat = (envelope as { data: Record }).data; } else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) { remoteFlat = envelope as Record; } else { throw new Error("Invalid cloud settings payload"); } const migrated = migrateLegacyToPluginSettings(remoteFlat); const remoteSanitized = stripExcludedKeysFromRemoteData(migrated); const local = (await browser.storage.local.get()) as Record; const preserve = collectLocalKeysToPreserve(local); await browser.storage.local.set({ ...remoteSanitized, ...preserve }); }