mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
tweaks and fixes to UI
This commit is contained in:
@@ -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 <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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()`.
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import type { SettingsState } from "@/types/storage";
|
import type { SettingsState } from "@/types/storage";
|
||||||
import { fetchNews } from "./background/news";
|
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() {
|
function reloadSeqtaPages() {
|
||||||
const result = browser.tabs.query({});
|
const result = browser.tabs.query({});
|
||||||
@@ -150,6 +157,87 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
|
|||||||
return true;
|
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<string, unknown>);
|
||||||
|
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 {
|
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { themeId, token, action } = request;
|
const { themeId, token, action } = request;
|
||||||
if (!themeId || !token) {
|
if (!themeId || !token) {
|
||||||
@@ -214,6 +302,8 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|||||||
cloudLogin: handleCloudLogin,
|
cloudLogin: handleCloudLogin,
|
||||||
cloudRefresh: handleCloudRefresh,
|
cloudRefresh: handleCloudRefresh,
|
||||||
cloudFavorite: handleCloudFavorite,
|
cloudFavorite: handleCloudFavorite,
|
||||||
|
cloudSettingsUpload: handleCloudSettingsUpload,
|
||||||
|
cloudSettingsDownload: handleCloudSettingsDownload,
|
||||||
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
|
let {
|
||||||
|
onClick,
|
||||||
|
text,
|
||||||
|
disabled = false,
|
||||||
|
} = $props<{
|
||||||
|
onClick: () => void;
|
||||||
|
text: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import DisclaimerModal from "./DisclaimerModal.svelte";
|
||||||
|
import Button from "./Button.svelte";
|
||||||
|
|
||||||
|
let cloudState = $state(cloudAuth.state);
|
||||||
|
let busy = $state(false);
|
||||||
|
let statusMessage = $state<string | null>(null);
|
||||||
|
let statusError = $state<string | null>(null);
|
||||||
|
let lastUploadAt = $state<string | null>(null);
|
||||||
|
let lastDownloadAt = $state<string | null>(null);
|
||||||
|
let showRestoreConfirm = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const unsub = cloudAuth.subscribe((s) => {
|
||||||
|
cloudState = s;
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatNow(): string {
|
||||||
|
return new Date().toLocaleString(undefined, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (!token) return;
|
||||||
|
busy = true;
|
||||||
|
statusError = null;
|
||||||
|
statusMessage = null;
|
||||||
|
try {
|
||||||
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudSettingsUpload",
|
||||||
|
token,
|
||||||
|
})) as { success?: boolean; error?: string };
|
||||||
|
if (res?.success) {
|
||||||
|
statusMessage = "Settings saved to the cloud.";
|
||||||
|
lastUploadAt = formatNow();
|
||||||
|
} else {
|
||||||
|
statusError = res?.error ?? "Upload failed";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusError = e instanceof Error ? e.message : "Upload failed";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptDownload() {
|
||||||
|
showRestoreConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDownload() {
|
||||||
|
showRestoreConfirm = false;
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
if (!token) return;
|
||||||
|
busy = true;
|
||||||
|
statusError = null;
|
||||||
|
statusMessage = null;
|
||||||
|
try {
|
||||||
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
type: "cloudSettingsDownload",
|
||||||
|
token,
|
||||||
|
})) as { success?: boolean; error?: string; notFound?: boolean };
|
||||||
|
if (res?.success) {
|
||||||
|
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded.";
|
||||||
|
lastDownloadAt = formatNow();
|
||||||
|
} else {
|
||||||
|
statusError = res?.error ?? "Download failed";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusError = e instanceof Error ? e.message : "Download failed";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="w-full rounded-xl border border-zinc-200/60 bg-zinc-50/80 px-3 py-2.5 dark:border-zinc-700/50 dark:bg-zinc-900/40"
|
||||||
|
>
|
||||||
|
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3>
|
||||||
|
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
text={busy ? "Please wait…" : "Upload to cloud"}
|
||||||
|
onClick={upload}
|
||||||
|
disabled={busy || !cloudState.isLoggedIn}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
text={busy ? "Please wait…" : "Download from cloud"}
|
||||||
|
onClick={promptDownload}
|
||||||
|
disabled={busy || !cloudState.isLoggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !cloudState.isLoggedIn}
|
||||||
|
<p class="mt-2 text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
Sign in from the BetterSEQTA Cloud header above to sync settings.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if statusMessage}
|
||||||
|
<p class="mt-2 text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if statusError}
|
||||||
|
<p class="mt-2 text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if lastUploadAt || lastDownloadAt}
|
||||||
|
<p class="mt-1 text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||||
|
{#if lastUploadAt}<span>Last upload: {lastUploadAt}</span>{/if}
|
||||||
|
{#if lastUploadAt && lastDownloadAt}<span class="mx-1">·</span>{/if}
|
||||||
|
{#if lastDownloadAt}<span>Last download: {lastDownloadAt}</span>{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRestoreConfirm}
|
||||||
|
<DisclaimerModal
|
||||||
|
title="Restore from cloud?"
|
||||||
|
message="This will replace BetterSEQTA+ settings in this browser with your cloud backup. Your BetterSEQTA Cloud sign-in on this device will be kept. Continue?"
|
||||||
|
onConfirm={confirmDownload}
|
||||||
|
onCancel={() => (showRestoreConfirm = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import Settings from "./settings/general.svelte";
|
import Settings from "./settings/general.svelte";
|
||||||
import Shortcuts from "./settings/shortcuts.svelte";
|
import Shortcuts from "./settings/shortcuts.svelte";
|
||||||
import Theme from "./settings/theme.svelte";
|
import Theme from "./settings/theme.svelte";
|
||||||
|
import CloudSync from "./settings/cloudSync.svelte";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
|
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
|
||||||
@@ -299,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
{ title: "Shortcuts", Content: Shortcuts },
|
{ title: "Shortcuts", Content: Shortcuts },
|
||||||
{ title: "Themes", Content: Theme },
|
{ title: "Themes", Content: Theme },
|
||||||
|
{ title: "Cloud", Content: CloudSync },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CloudSettingsSync />
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
|
||||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
|
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||||
|
|
||||||
import { getAllPluginSettings } from "@/plugins"
|
import { getAllPluginSettings } from "@/plugins"
|
||||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||||
@@ -97,6 +98,19 @@
|
|||||||
showColourPicker: () => void;
|
showColourPicker: () => void;
|
||||||
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => 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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
{#snippet Setting({ title, description, Component, props }: SettingsList) }
|
||||||
@@ -439,6 +453,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
||||||
|
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<string>([
|
||||||
|
...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<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(remote)) {
|
||||||
|
if (shouldOmitKeyFromCloudPayload(k)) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUploadPayload(all: Record<string, unknown>): {
|
||||||
|
schemaVersion: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const all = await browser.storage.local.get();
|
||||||
|
return buildUploadPayload(all as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
let remoteFlat: Record<string, unknown>;
|
||||||
|
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<string, unknown> }).data;
|
||||||
|
} else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
|
||||||
|
remoteFlat = envelope as Record<string, unknown>;
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user