From ea4a2c1ff0c65a97e675f01ffffbda6fff41a9f9 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 8 Apr 2026 08:29:25 +0930 Subject: [PATCH] feat: auto sync for cloud and fix some firefox weirdness --- docs/CLOUD_SETTINGS_SYNC_SERVER.md | 15 +- lib/firefoxStripFunctionProbe.ts | 43 ++ src/SEQTA.ts | 4 + src/background.ts | 70 +-- src/background/cloudSettingsAutoSync.ts | 406 ++++++++++++++++++ .../components/CloudSettingsSync.svelte | 33 ++ src/manifests/manifest.json | 2 +- src/seqta/utils/cloudSettingsSync.ts | 25 +- src/types/storage.ts | 2 + vite.config.ts | 8 +- 10 files changed, 555 insertions(+), 53 deletions(-) create mode 100644 lib/firefoxStripFunctionProbe.ts create mode 100644 src/background/cloudSettingsAutoSync.ts diff --git a/docs/CLOUD_SETTINGS_SYNC_SERVER.md b/docs/CLOUD_SETTINGS_SYNC_SERVER.md index 655ebbfe..c9c085af 100644 --- a/docs/CLOUD_SETTINGS_SYNC_SERVER.md +++ b/docs/CLOUD_SETTINGS_SYNC_SERVER.md @@ -113,10 +113,21 @@ The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not* - **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`. - **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data). - **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced. +- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob). On restore, those keys are **not** taken from the server; the device keeps its current local values. +## Related endpoint: `GET /api/user/cloud-summary` + +The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob. + +- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore). +- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual. + +This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`). + ## Client reference (extension) -- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys and sensitive device keys above). -- Download: `applyDownloadedEnvelope` after `GET`; local auth keys and sensitive device keys are merged back after `chrome.storage.local.clear()`. +- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**). +- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`. +- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`. diff --git a/lib/firefoxStripFunctionProbe.ts b/lib/firefoxStripFunctionProbe.ts new file mode 100644 index 00000000..8ded81c4 --- /dev/null +++ b/lib/firefoxStripFunctionProbe.ts @@ -0,0 +1,43 @@ +import type { Plugin } from "vite"; + +/** + * Firefox extension pages forbid eval / `Function` constructor. Some deps still emit: + * - `Function(\`return this\`)()` (lodash-style global) + * - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT) + */ +export function firefoxStripFunctionProbe(): Plugin { + return { + name: "firefox-strip-function-probe", + apply: "build", + enforce: "post", + generateBundle(_options, bundle) { + if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return; + + const literalReplacements: [string, string][] = [ + ['try{return new Function(""),!0}catch{return!1}', "return!1"], + ["try{return new Function(''),!0}catch{return!1}", "return!1"], + ['try{return new Function(""),true}catch{return false}', "return false"], + ["try{return new Function(''),true}catch{return false}", "return false"], + // Empty template literal probe (minifier output) + ["try{return Function(``),!0}catch{return!1}", "return!1"], + ]; + + for (const chunk of Object.values(bundle)) { + if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue; + let { code } = chunk; + + code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)"); + code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)"); + code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)"); + + for (const [from, to] of literalReplacements) { + if (code.includes(from)) { + code = code.split(from).join(to); + } + } + + chunk.code = code; + } + }, + }; +} diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 92bae369..60a6b6a7 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -59,6 +59,10 @@ async function init() { IsSEQTAPage = true; console.info("[BetterSEQTA+] Verified SEQTA Page"); + if (typeof window !== "undefined" && window === window.top) { + void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {}); + } + registerFetchSeqtaAppLinkListener(); const documentLoadStyle = document.createElement("style"); diff --git a/src/background.ts b/src/background.ts index 4abe07e5..acdeddae 100644 --- a/src/background.ts +++ b/src/background.ts @@ -2,12 +2,11 @@ import browser from "webextension-polyfill"; import type { SettingsState } from "@/types/storage"; import { fetchNews } from "./background/news"; import { - applyDownloadedEnvelope, - buildUploadPayload, -} from "@/seqta/utils/cloudSettingsSync"; - -const CLOUD_SETTINGS_SYNC_URL = - "https://accounts.betterseqta.org/api/bsplus/settings/sync"; + initCloudSettingsAutoSync, + performCloudSettingsDownloadWithRetry, + performCloudSettingsUploadWithRetry, + runCloudSettingsPoll, +} from "./background/cloudSettingsAutoSync"; function reloadSeqtaPages() { const result = browser.tabs.query({}); @@ -165,25 +164,12 @@ function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): b sendResponse({ success: false, error: "Not authenticated" }); return; } - const all = await browser.storage.local.get(); - const payload = buildUploadPayload(all as Record); - const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), + const res = await performCloudSettingsUploadWithRetry(token); + sendResponse({ + success: res.success, + error: res.error, + updated_at: res.updated_at, }); - const data = await parseJsonResponse(r); - if (!r.ok) { - sendResponse({ - success: false, - error: data?.error ?? `Upload failed (${r.status})`, - }); - return; - } - sendResponse({ success: true, updated_at: data?.updated_at }); } catch (err) { console.error("[Background] cloudSettingsUpload error:", err); sendResponse({ @@ -203,30 +189,13 @@ function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): sendResponse({ success: false, error: "Not authenticated" }); return; } - const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", + const res = await performCloudSettingsDownloadWithRetry(token); + sendResponse({ + success: res.success, + notFound: res.notFound, + error: res.error, + updated_at: res.updated_at, }); - const data = await parseJsonResponse(r); - if (r.status === 404) { - sendResponse({ - success: false, - notFound: true, - error: "No settings backup found in the cloud", - }); - return; - } - if (!r.ok) { - sendResponse({ - success: false, - error: data?.error ?? `Download failed (${r.status})`, - }); - return; - } - await applyDownloadedEnvelope(data); - reloadSeqtaPages(); - sendResponse({ success: true, updated_at: data?.updated_at }); } catch (err) { console.error("[Background] cloudSettingsDownload error:", err); sendResponse({ @@ -304,6 +273,10 @@ const MESSAGE_HANDLERS: Record = { cloudFavorite: handleCloudFavorite, cloudSettingsUpload: handleCloudSettingsUpload, cloudSettingsDownload: handleCloudSettingsDownload, + cloudSettingsPoll: () => { + void runCloudSettingsPoll(); + return false; + }, getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => { (async () => { try { @@ -422,6 +395,7 @@ function getDefaultValues(): SettingsState { adaptiveThemeColour: false, adaptiveThemeGradient: false, adaptiveThemeColourTransition: true, + autoCloudSettingsSync: true, }; } @@ -439,3 +413,5 @@ browser.runtime.onInstalled.addListener(function (event) { browser.storage.local.set({ justupdated: true }); } }); + +initCloudSettingsAutoSync({ reloadSeqtaPages }); diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts new file mode 100644 index 00000000..8aa23112 --- /dev/null +++ b/src/background/cloudSettingsAutoSync.ts @@ -0,0 +1,406 @@ +import browser from "webextension-polyfill"; +import { + applyDownloadedEnvelope, + buildUploadPayload, + BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, + CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + isKeyIncludedInCloudUploadPayload, + setKnownRemoteUpdatedAt, +} from "@/seqta/utils/cloudSettingsSync"; + +const ACCOUNTS_BASE = "https://accounts.betterseqta.org"; +export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`; +const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`; +const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`; + +const ALARM_NAME = "bsplus_cloud_settings_auto_sync"; +const PERIOD_MINUTES = 60; +const UPLOAD_DEBOUNCE_MS = 2000; + +type CloudSummaryResponse = { + desqta?: unknown; + bsplus?: { updated_at: string; schemaVersion: number } | null; +}; + +let reloadSeqtaPagesFn: (() => void) | null = null; +let suppressAutoUploadDuringRestore = false; +let debounceTimer: ReturnType | null = null; +let pollInFlight: Promise | null = null; + +function isAutoCloudSyncEnabled(all: Record): boolean { + return all.autoCloudSettingsSync !== false; +} + +async function parseJsonResponse(r: Response): Promise { + const text = await r.text(); + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} + +async function getAccessToken(): Promise { + const { bsplus_token } = await browser.storage.local.get("bsplus_token"); + return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null; +} + +async function tryRefreshTokens(): Promise { + const result = await browser.storage.local.get([ + "bsplus_refresh_token", + "bsplus_client_id", + "bsplus_user", + ]); + const refresh_token = result.bsplus_refresh_token as string | undefined; + const client_id = result.bsplus_client_id as string | undefined; + if (!refresh_token || !client_id) return false; + + try { + const r = await fetch(REFRESH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token, client_id }), + }); + const data = await parseJsonResponse(r); + if (!r.ok || !data.access_token || !data.refresh_token) return false; + + await browser.storage.local.set({ + bsplus_token: data.access_token, + bsplus_refresh_token: data.refresh_token, + bsplus_user: data.user ?? result.bsplus_user, + }); + return true; + } catch { + return false; + } +} + +function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean { + const a = Date.parse(serverIso); + if (Number.isNaN(a)) return false; + if (localIso === undefined || localIso === "") return true; + const b = Date.parse(localIso); + if (Number.isNaN(b)) return true; + return a > b; +} + +async function fetchCloudSummaryOnce( + token: string, +): Promise< + | { ok: true; data: CloudSummaryResponse } + | { ok: false; unauthorized: boolean; error?: string } +> { + try { + const r = await fetch(CLOUD_SUMMARY_URL, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + const data = (await parseJsonResponse(r)) as CloudSummaryResponse; + if (r.status === 401) return { ok: false, unauthorized: true }; + if (!r.ok) { + return { + ok: false, + unauthorized: false, + error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`, + }; + } + return { ok: true, data }; + } catch (e) { + return { + ok: false, + unauthorized: false, + error: e instanceof Error ? e.message : "Network error", + }; + } +} + +async function fetchCloudSummaryWithAuthRetry( + token: string, +): Promise { + let t = token; + for (let attempt = 0; attempt < 2; attempt++) { + const res = await fetchCloudSummaryOnce(t); + if (res.ok) return res.data; + if (res.unauthorized && attempt === 0) { + const refreshed = await tryRefreshTokens(); + if (!refreshed) break; + const next = await getAccessToken(); + if (!next) break; + t = next; + continue; + } + if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error); + break; + } + return null; +} + +type PutResult = + | { ok: true; updated_at?: string } + | { ok: false; unauthorized: boolean; error?: string }; + +async function putSettingsOnce(token: string): Promise { + try { + const all = await browser.storage.local.get(); + const payload = buildUploadPayload(all as Record); + const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await parseJsonResponse(r); + if (r.status === 401) return { ok: false, unauthorized: true }; + if (!r.ok) { + return { + ok: false, + unauthorized: false, + error: data?.error ?? `Upload failed (${r.status})`, + }; + } + const updated_at = data?.updated_at as string | undefined; + await setKnownRemoteUpdatedAt(updated_at); + return { ok: true, updated_at }; + } catch (e) { + return { + ok: false, + unauthorized: false, + error: e instanceof Error ? e.message : "Upload failed", + }; + } +} + +export async function performCloudSettingsUploadWithRetry( + token: string, +): Promise<{ success: boolean; error?: string; updated_at?: string }> { + let t = token; + for (let attempt = 0; attempt < 2; attempt++) { + const res = await putSettingsOnce(t); + if (res.ok) return { success: true, updated_at: res.updated_at }; + if (res.unauthorized && attempt === 0) { + const refreshed = await tryRefreshTokens(); + if (!refreshed) return { success: false, error: "Not authenticated" }; + const next = await getAccessToken(); + if (!next) return { success: false, error: "Not authenticated" }; + t = next; + continue; + } + return { success: false, error: res.error ?? "Upload failed" }; + } + return { success: false, error: "Upload failed" }; +} + +type GetResult = + | { ok: true; updated_at?: string } + | { ok: false; notFound?: boolean; unauthorized: boolean; error?: string }; + +async function getSettingsAndApplyOnce(token: string): Promise { + try { + const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + const data = await parseJsonResponse(r); + if (r.status === 401) return { ok: false, unauthorized: true }; + if (r.status === 404) { + return { + ok: false, + notFound: true, + unauthorized: false, + error: "No settings backup found in the cloud", + }; + } + if (!r.ok) { + return { + ok: false, + unauthorized: false, + error: data?.error ?? `Download failed (${r.status})`, + }; + } + await applyDownloadedEnvelope(data); + reloadSeqtaPagesFn?.(); + const updated_at = data?.updated_at as string | undefined; + await setKnownRemoteUpdatedAt(updated_at); + return { ok: true, updated_at }; + } catch (e) { + return { + ok: false, + unauthorized: false, + error: e instanceof Error ? e.message : "Download failed", + }; + } +} + +export async function performCloudSettingsDownloadWithRetry( + token: string, +): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> { + suppressAutoUploadDuringRestore = true; + try { + let t = token; + for (let attempt = 0; attempt < 2; attempt++) { + const res = await getSettingsAndApplyOnce(t); + if (res.ok) return { success: true, updated_at: res.updated_at }; + if (res.unauthorized && attempt === 0) { + const refreshed = await tryRefreshTokens(); + if (!refreshed) return { success: false, error: "Not authenticated" }; + const next = await getAccessToken(); + if (!next) return { success: false, error: "Not authenticated" }; + t = next; + continue; + } + return { + success: false, + notFound: res.notFound, + error: res.error ?? "Download failed", + }; + } + return { success: false, error: "Download failed" }; + } finally { + suppressAutoUploadDuringRestore = false; + } +} + +async function maybeUploadBaseline(token: string): Promise { + const res = await performCloudSettingsUploadWithRetry(token); + if (!res.success) { + console.warn("[BS+ cloud sync] Baseline upload failed:", res.error); + } +} + +async function downloadIfNeeded(token: string): Promise { + const res = await performCloudSettingsDownloadWithRetry(token); + if (!res.success && !res.notFound) { + console.warn("[BS+ cloud sync] Auto-download failed:", res.error); + } +} + +async function runCloudSettingsPollInner(): Promise { + const all = (await browser.storage.local.get()) as Record; + if (!isAutoCloudSyncEnabled(all)) return; + + let token = await getAccessToken(); + if (!token) return; + + const summary = await fetchCloudSummaryWithAuthRetry(token); + if (!summary) return; + + const bsplus = summary.bsplus; + const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined; + + if ( + bsplus && + typeof bsplus.schemaVersion === "number" && + bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION + ) { + console.warn( + "[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download", + ); + return; + } + + token = (await getAccessToken()) ?? token; + + if (!watermark) { + if (!bsplus?.updated_at) { + await maybeUploadBaseline(token); + return; + } + await downloadIfNeeded(token); + return; + } + + if (!bsplus?.updated_at) return; + + if (isServerTimestampNewer(bsplus.updated_at, watermark)) { + await downloadIfNeeded(token); + } +} + +export function runCloudSettingsPoll(): Promise { + if (pollInFlight) return pollInFlight; + pollInFlight = (async () => { + try { + await runCloudSettingsPollInner(); + } catch (e) { + console.error("[BS+ cloud sync] Poll error:", e); + } finally { + pollInFlight = null; + } + })(); + return pollInFlight; +} + +function clearUploadDebounce(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } +} + +function scheduleDebouncedUpload(): void { + if (suppressAutoUploadDuringRestore) return; + clearUploadDebounce(); + debounceTimer = setTimeout(() => { + debounceTimer = null; + void runDebouncedUploadJob(); + }, UPLOAD_DEBOUNCE_MS); +} + +async function runDebouncedUploadJob(): Promise { + const all = (await browser.storage.local.get()) as Record; + if (!isAutoCloudSyncEnabled(all)) return; + const token = await getAccessToken(); + if (!token) return; + const res = await performCloudSettingsUploadWithRetry(token); + if (!res.success) { + console.warn("[BS+ cloud sync] Auto-upload failed:", res.error); + } +} + +async function syncAlarmWithStorage(): Promise { + const all = (await browser.storage.local.get()) as Record; + if (!isAutoCloudSyncEnabled(all)) { + await browser.alarms.clear(ALARM_NAME); + clearUploadDebounce(); + return; + } + await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES }); +} + +function onStorageChanged( + changes: Record, + area: string, +): void { + if (area !== "local") return; + + if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) { + void syncAlarmWithStorage(); + } + + const keys = Object.keys(changes); + if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return; + + void (async () => { + const all = (await browser.storage.local.get()) as Record; + if (!isAutoCloudSyncEnabled(all)) return; + if (suppressAutoUploadDuringRestore) return; + if (!(await getAccessToken())) return; + scheduleDebouncedUpload(); + })(); +} + +function onAlarm(alarm: browser.Alarms.Alarm): void { + if (alarm.name !== ALARM_NAME) return; + void runCloudSettingsPoll(); +} + +export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { + reloadSeqtaPagesFn = deps.reloadSeqtaPages; + browser.alarms.onAlarm.addListener(onAlarm); + browser.storage.onChanged.addListener(onStorageChanged); + void syncAlarmWithStorage(); +} + diff --git a/src/interface/components/CloudSettingsSync.svelte b/src/interface/components/CloudSettingsSync.svelte index 829cef82..b50fe6b4 100644 --- a/src/interface/components/CloudSettingsSync.svelte +++ b/src/interface/components/CloudSettingsSync.svelte @@ -1,8 +1,10 @@