diff --git a/docs/CLOUD_SETTINGS_SYNC_SERVER.md b/docs/CLOUD_SETTINGS_SYNC_SERVER.md new file mode 100644 index 00000000..655ebbfe --- /dev/null +++ b/docs/CLOUD_SETTINGS_SYNC_SERVER.md @@ -0,0 +1,122 @@ +# BetterSEQTA Cloud — settings sync (server specification) + +This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints. + +## Purpose + +- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extension’s `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.). +- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client. +- **Download** replaces local storage with the stored snapshot, then the client reapplies the current device’s session tokens so the user stays signed in. + +## Base URL + +All routes below are relative to: + +`https://accounts.betterseqta.org` + +## Authentication + +Every request must include: + +```http +Authorization: Bearer +``` + +Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user. + +## Endpoints + +### `PUT /api/bsplus/settings/sync` + +Upserts the caller’s settings backup. + +**Request body (JSON):** + +```json +{ + "schemaVersion": 1, + "data": { + "...": "flat key-value map mirroring extension storage (see Payload shape)" + } +} +``` + +- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. +- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). + +**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example: + +```json +{ + "updated_at": "2026-04-07T12:00:00.000Z" +} +``` + +`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. + +**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.). + +--- + +### `GET /api/bsplus/settings/sync` + +Returns the caller’s latest settings backup. + +**Success response:** HTTP `200` with body: + +```json +{ + "schemaVersion": 1, + "data": { }, + "updated_at": "2026-04-07T12:00:00.000Z" +} +``` + +- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys). +- **`schemaVersion`**: optional but recommended; should match what was stored. +- **`updated_at`**: optional; included for UX if the client shows “last backup” time. + +**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user. + +**Error responses:** `401` if the token is invalid, etc. + +--- + +## Suggested database shape + +Example relational layout: + +| Column | Type | Notes | +|---------------|-------------|--------| +| `user_id` | FK → users | Unique per backup row (one row per user). | +| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. | +| `updated_at` | timestamptz | Set on each successful `PUT`. | + +Unique constraint on `user_id`. + +## Semantics + +- **Last write wins:** each `PUT` replaces the stored backup for that user. +- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1). + +## Security and privacy + +- **Encryption at rest** for `payload` is recommended. +- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy. +- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload. + +### Never included in the sync payload (`chrome.storage.local` only) + +The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include: + +- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here. +- **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. + +On restore, those keys are **not** taken from the server; the device keeps its current local values. + +## 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()`. diff --git a/src/background.ts b/src/background.ts index 6a897ffb..4abe07e5 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,6 +1,13 @@ 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"; function reloadSeqtaPages() { const result = browser.tabs.query({}); @@ -150,6 +157,87 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean return true; } +function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean { + void (async () => { + try { + const token = request.token as string | undefined; + if (!token) { + 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 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({ + success: false, + error: err instanceof Error ? err.message : "Upload failed", + }); + } + })(); + return true; +} + +function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean { + void (async () => { + try { + const token = request.token as string | undefined; + if (!token) { + 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 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({ + success: false, + error: err instanceof Error ? err.message : "Download failed", + }); + } + })(); + return true; +} + function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { const { themeId, token, action } = request; if (!themeId || !token) { @@ -214,6 +302,8 @@ const MESSAGE_HANDLERS: Record = { cloudLogin: handleCloudLogin, cloudRefresh: handleCloudRefresh, cloudFavorite: handleCloudFavorite, + cloudSettingsUpload: handleCloudSettingsUpload, + cloudSettingsDownload: handleCloudSettingsDownload, getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => { (async () => { try { diff --git a/src/interface/components/Button.svelte b/src/interface/components/Button.svelte index 2227c47d..9cb9a03a 100644 --- a/src/interface/components/Button.svelte +++ b/src/interface/components/Button.svelte @@ -1,7 +1,20 @@ - \ No newline at end of file diff --git a/src/interface/components/CloudSettingsSync.svelte b/src/interface/components/CloudSettingsSync.svelte new file mode 100644 index 00000000..cf7d52a1 --- /dev/null +++ b/src/interface/components/CloudSettingsSync.svelte @@ -0,0 +1,133 @@ + + +
+

Cloud settings backup

+

+ Upload copies this browser’s BetterSEQTA+ settings to your account. Download replaces local settings with the + cloud copy (your sign-in stays on this device). +

+ +
+
+ + {#if !cloudState.isLoggedIn} +

+ Sign in from the BetterSEQTA Cloud header above to sync settings. +

+ {/if} + + {#if statusMessage} +

{statusMessage}

+ {/if} + {#if statusError} +

{statusError}

+ {/if} + {#if lastUploadAt || lastDownloadAt} +

+ {#if lastUploadAt}Last upload: {lastUploadAt}{/if} + {#if lastUploadAt && lastDownloadAt}·{/if} + {#if lastDownloadAt}Last download: {lastDownloadAt}{/if} +

+ {/if} +
+ +{#if showRestoreConfirm} + (showRestoreConfirm = false)} + /> +{/if} diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index f6d2746d..e637f9e4 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -3,6 +3,7 @@ import Settings from "./settings/general.svelte"; import Shortcuts from "./settings/shortcuts.svelte"; import Theme from "./settings/theme.svelte"; + import CloudSync from "./settings/cloudSync.svelte"; import browser from "webextension-polyfill"; import { standalone as StandaloneStore } from "../utils/standalone.svelte"; @@ -299,6 +300,7 @@ }, { title: "Shortcuts", Content: Shortcuts }, { title: "Themes", Content: Theme }, + { title: "Cloud", Content: CloudSync }, ]} /> diff --git a/src/interface/pages/settings/cloudSync.svelte b/src/interface/pages/settings/cloudSync.svelte new file mode 100644 index 00000000..e4df2510 --- /dev/null +++ b/src/interface/pages/settings/cloudSync.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 1e6e414f..08f54f28 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -13,6 +13,7 @@ import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" + import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" import { getAllPluginSettings } from "@/plugins" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" @@ -97,6 +98,19 @@ showColourPicker: () => void; showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; }>(); + + async function exportCloudSettingsJsonToFile() { + const payload = await getSnapshotForUpload(); + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + a.click(); + URL.revokeObjectURL(url); + } {#snippet Setting({ title, description, Component, props }: SettingsList) } @@ -439,6 +453,15 @@ /> +
+
+

Export cloud settings JSON

+

Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.

+
+
+
+
{/if} diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts new file mode 100644 index 00000000..e8ff29bb --- /dev/null +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -0,0 +1,131 @@ +import browser from "webextension-polyfill"; + +/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */ +export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; + +/** + * 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", +] 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; + +/** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */ +export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] 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", +] as const; + +const OMIT_FROM_UPLOAD_EXACT = new Set([ + ...KEYS_OMITTED_FROM_CLOUD_UPLOAD, + ...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT, +]); + +function shouldOmitKeyFromCloudPayload(key: string): boolean { + 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 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, 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 (shouldOmitKeyFromCloudPayload(k)) continue; + out[k] = v; + } + return out; +} + +export function buildUploadPayload(all: Record): { + schemaVersion: number; + data: Record; +} { + const data: Record = {}; + for (const [k, v] of Object.entries(all)) { + if (shouldOmitKeyFromCloudPayload(k)) continue; + data[k] = v; + } + return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; +} + +export async function getSnapshotForUpload(): Promise<{ + schemaVersion: number; + data: Record; +}> { + const all = await browser.storage.local.get(); + return buildUploadPayload(all as Record); +} + +/** + * Replace local extension storage with the downloaded snapshot, except auth keys + * and device-only sensitive caches, which are preserved from the current device. + */ +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 local = await browser.storage.local.get(); + const preserved = collectLocalKeysToPreserve(local); + const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat); + + await browser.storage.local.clear(); + await browser.storage.local.set({ ...remoteSanitized, ...preserved }); +}