diff --git a/src/background.ts b/src/background.ts index c1248182..30d2165f 100644 --- a/src/background.ts +++ b/src/background.ts @@ -11,6 +11,7 @@ import { requestCloudSettingsDebouncedUpload, runCloudSettingsPoll, } from "./background/cloudSettingsAutoSync"; +import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; /** * Session-only dev-mode override of the content API base. @@ -45,6 +46,11 @@ function reloadSeqtaPages() { /** Callback for sending a response back to the message sender */ type MessageSender = { (response?: unknown): void }; +async function getAccessTokenFromStorage(): Promise { + const { bsplus_token } = await browser.storage.local.get("bsplus_token"); + return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null; +} + /** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */ function normalizeFetchThemesResponse(json: unknown): { success: boolean; @@ -79,66 +85,101 @@ function normalizeFetchThemesResponse(json: unknown): { } function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { - const { token } = request; - const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; - const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; - const headers: Record = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - fetch(apiUrl, { cache: "no-store", headers }) - .then(async (r) => { - const json = await r.json(); - if (!r.ok) { - throw new Error( - (json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string" - ? (json as { error: string }).error - : null) ?? `Themes API HTTP ${r.status}`, - ); - } - return normalizeFetchThemesResponse(json); - }) - .then(sendResponse) - .catch((err) => { - console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); - fetch(githubUrl, { cache: "no-store" }) - .then(async (r) => { - if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`); - const data = await r.json(); - const themes = Array.isArray(data) ? data : (data?.themes ?? []); - return normalizeFetchThemesResponse({ success: true, data: { themes } }); - }) - .then(sendResponse) - .catch((fallbackErr) => { - console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); - sendResponse({ success: false, error: fallbackErr?.message }); - }); - }); + void (async () => { + const token = await getAccessTokenFromStorage(); + const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; + const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(apiUrl, { cache: "no-store", headers }) + .then(async (r) => { + const json = await r.json(); + if (!r.ok) { + throw new Error( + (json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string" + ? (json as { error: string }).error + : null) ?? `Themes API HTTP ${r.status}`, + ); + } + return normalizeFetchThemesResponse(json); + }) + .then(sendResponse) + .catch((err) => { + console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); + fetch(githubUrl, { cache: "no-store" }) + .then(async (r) => { + if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`); + const data = await r.json(); + const themes = Array.isArray(data) ? data : (data?.themes ?? []); + return normalizeFetchThemesResponse({ success: true, data: { themes } }); + }) + .then(sendResponse) + .catch((fallbackErr) => { + console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); + sendResponse({ success: false, error: fallbackErr?.message }); + }); + }); + })(); return true; } function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean { - const { themeId, token } = request; + const { themeId } = request; if (!themeId || typeof themeId !== "string") { sendResponse({ success: false, error: "Missing themeId" }); return false; } - const headers: Record = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) - .then((r) => r.json()) - .then(sendResponse) - .catch((err) => { - console.error("[Background] fetchThemeDetails error:", err); - sendResponse({ success: false, error: err?.message }); - }); + void (async () => { + const token = await getAccessTokenFromStorage(); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] fetchThemeDetails error:", err); + sendResponse({ success: false, error: err?.message }); + }); + })(); return true; } -function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean { +function isTrustedSender(sender?: browser.Runtime.MessageSender): boolean { + if (!sender) return false; + if (sender.id && sender.id !== browser.runtime.id) return false; + + const urls = [sender.url, sender.tab?.url].filter(Boolean) as string[]; + for (const pageUrl of urls) { + if (/^chrome-extension:\/\//.test(pageUrl) || /^moz-extension:\/\//.test(pageUrl)) { + return true; + } + try { + if (isSeqtaOrigin(new URL(pageUrl).origin)) return true; + } catch { + // try next URL + } + } + return false; +} + +function handleFetchFromUrl( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ error: "Unauthorized sender" }); + return false; + } const { url } = request; if (!url || typeof url !== "string") { sendResponse({ error: "Missing url" }); return false; } + if (!isAllowedFetchUrl(url)) { + sendResponse({ error: "URL not allowed" }); + return false; + } fetch(url, { cache: "no-store" }) .then((r) => r.json()) .then((data) => sendResponse({ data })) @@ -177,7 +218,15 @@ function handleCloudReserveClient(request: any, sendResponse: MessageSender): bo return true; } -function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { +function handleCloudLogin( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ error: "Unauthorized sender" }); + return false; + } const { client_id, redirect_uri, login, password } = request; if (!client_id || !redirect_uri || !login || !password) { sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); @@ -291,10 +340,18 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean return true; } -function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean { +function handleCloudSettingsUpload( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ success: false, error: "Unauthorized sender" }); + return false; + } void (async () => { try { - const token = request.token as string | undefined; + const token = await getAccessTokenFromStorage(); if (!token) { sendResponse({ success: false, error: "Not authenticated" }); return; @@ -316,10 +373,18 @@ function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): b return true; } -function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean { +function handleCloudSettingsDownload( + request: any, + sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) { + sendResponse({ success: false, error: "Unauthorized sender" }); + return false; + } void (async () => { try { - const token = request.token as string | undefined; + const token = await getAccessTokenFromStorage(); if (!token) { sendResponse({ success: false, error: "Not authenticated" }); return; @@ -343,22 +408,29 @@ function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): } function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { - const { themeId, token, action } = request; - if (!themeId || !token) { - sendResponse({ success: false, error: "Theme ID and token required" }); + const { themeId, action } = request; + if (!themeId) { + sendResponse({ success: false, error: "Theme ID required" }); return false; } - const isFavorite = action === "favorite"; - fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { - method: isFavorite ? "POST" : "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }) - .then((r) => r.json()) - .then(sendResponse) - .catch((err) => { - console.error("[Background] cloudFavorite error:", err); - sendResponse({ success: false, error: err?.message }); - }); + void (async () => { + const token = await getAccessTokenFromStorage(); + if (!token) { + sendResponse({ success: false, error: "Not authenticated" }); + return; + } + const isFavorite = action === "favorite"; + fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { + method: isFavorite ? "POST" : "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] cloudFavorite error:", err); + sendResponse({ success: false, error: err?.message }); + }); + })(); return true; } @@ -376,7 +448,12 @@ function isSeqtaOrigin(origin: string): boolean { } } -function handleSetDevApiBase(request: any): boolean { +function handleSetDevApiBase( + request: any, + _sendResponse: MessageSender, + sender?: browser.Runtime.MessageSender, +): boolean { + if (!isTrustedSender(sender)) return false; const url = typeof request?.url === "string" ? request.url.trim() : null; if (url && /^https?:\/\//.test(url)) { DEV_API_BASE = url.replace(/\/$/, ""); @@ -415,7 +492,11 @@ const MESSAGE_HANDLERS: Record = { }); return true; }, - sendNews: (req, sendResponse) => { + sendNews: (req, sendResponse, sender) => { + if (!isTrustedSender(sender)) { + sendResponse({ error: "Unauthorized sender" }); + return false; + } fetchNews(req.source ?? "australia", sendResponse); return true; }, diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts index 37224124..549b6d9c 100644 --- a/src/background/cloudSettingsAutoSync.ts +++ b/src/background/cloudSettingsAutoSync.ts @@ -25,6 +25,11 @@ const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`; const UPLOAD_DEBOUNCE_MS = 2000; const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000; const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll"; +const FETCH_TIMEOUT_MS = 30_000; + +function fetchWithTimeout(url: string, init?: RequestInit): Promise { + return fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); +} type CloudSummaryResponse = { desqta?: unknown; @@ -35,6 +40,7 @@ let reloadSeqtaPagesFn: (() => void) | null = null; let suppressAutoUploadDuringRestore = false; let debounceTimer: ReturnType | null = null; let pollInFlight: Promise | null = null; +let autoSyncInitialized = false; function isAutoCloudSyncEnabled(all: Record): boolean { return all.autoCloudSettingsSync !== false; @@ -65,7 +71,7 @@ async function tryRefreshTokens(): Promise { if (!refresh_token || !client_id) return false; try { - const r = await fetch(REFRESH_URL, { + const r = await fetchWithTimeout(REFRESH_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token, client_id }), @@ -100,7 +106,7 @@ async function fetchCloudSummaryOnce( | { ok: false; unauthorized: boolean; error?: string } > { try { - const r = await fetch(CLOUD_SUMMARY_URL, { + const r = await fetchWithTimeout(CLOUD_SUMMARY_URL, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", }); @@ -177,7 +183,7 @@ async function putSettingsOnce(token: string): Promise { return { ok: true, skipped: true }; } - const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { + const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, { method: "PUT", headers: { Authorization: `Bearer ${token}`, @@ -235,7 +241,7 @@ type GetResult = async function getSettingsAndApplyOnce(token: string): Promise { try { - const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { + const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, { method: "GET", headers: { Authorization: `Bearer ${token}` }, cache: "no-store", @@ -373,8 +379,8 @@ export function runCloudSettingsPoll(): Promise { try { const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY); if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return; - await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() }); await runCloudSettingsPollInner(); + await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() }); } catch (e) { console.error("[BS+ cloud sync] Poll error:", e); } finally { @@ -453,6 +459,8 @@ function onStorageChanged( export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { reloadSeqtaPagesFn = deps.reloadSeqtaPages; + if (autoSyncInitialized) return; + autoSyncInitialized = true; browser.storage.onChanged.addListener(onStorageChanged); } diff --git a/src/background/news.ts b/src/background/news.ts index 70bcf1b2..d62a5378 100644 --- a/src/background/news.ts +++ b/src/background/news.ts @@ -1,5 +1,7 @@ import Parser from "rss-parser"; +const MAX_RATE_LIMIT_RETRIES = 3; + /** * Fetches news articles specifically for Australia from the NewsAPI. * @@ -13,15 +15,23 @@ import Parser from "rss-parser"; * to send the fetched news data back to the caller. * It's called with an object like `{ news: responseData }`. */ -const fetchAustraliaNews = async (url: string, sendResponse: any) => { +const fetchAustraliaNews = async ( + url: string, + sendResponse: any, + rateLimitRetryCount = 0, +) => { fetch(url) .then((result) => result.json()) .then((response) => { - if (response.code == "rateLimited") { - fetchAustraliaNews((url += "%00"), sendResponse); + if (response.code == "rateLimited" && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) { + fetchAustraliaNews(`${url}%00`, sendResponse, rateLimitRetryCount + 1); } else { sendResponse({ news: response }); } + }) + .catch((error) => { + console.error("[BetterSEQTA+] Failed to fetch Australia news", error); + sendResponse({ news: { articles: [] } }); }); }; @@ -99,13 +109,14 @@ export async function fetchNews(source: string | undefined, sendResponse: any) { if (normalizedSource === "australia") { const date = new Date(); + date.setDate(date.getDate() - 5); const from = date.getFullYear() + "-" + - (date.getMonth() + 1) + + String(date.getMonth() + 1).padStart(2, "0") + "-" + - (date.getDate() - 5); + String(date.getDate()).padStart(2, "0"); const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`; fetchAustraliaNews(url, sendResponse); @@ -115,7 +126,6 @@ export async function fetchNews(source: string | undefined, sendResponse: any) { const parser = new Parser(); let feeds: string[]; - console.log("fetchNews", normalizedSource); if (rssFeedsByCountry[normalizedSource.toLowerCase()]) { feeds = rssFeedsByCountry[normalizedSource.toLowerCase()]; @@ -129,6 +139,10 @@ export async function fetchNews(source: string | undefined, sendResponse: any) { const articlesPromises = feeds.map(async (feedUrl) => { try { const response = await fetch(feedUrl); + if (!response.ok) { + console.error(`Failed to fetch RSS feed: ${feedUrl} (${response.status})`); + return []; + } const feedString = await response.text(); const feed = await parser.parseString(feedString); diff --git a/src/interface/components/CloudPanel.svelte b/src/interface/components/CloudPanel.svelte index fb0ce6a5..7c3ef2e0 100644 --- a/src/interface/components/CloudPanel.svelte +++ b/src/interface/components/CloudPanel.svelte @@ -92,7 +92,7 @@ bind:this={background} class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50" onclick={handleBackgroundClick} - onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }} + onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick(e as unknown as MouseEvent) }} >
(null) let view: EditorView | null = null; + let unsubSettings: (() => void) | undefined; let editorTheme = new Compartment(); let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>() @@ -73,7 +74,7 @@ view = createEditorView(state, editor as HTMLElement); } - settingsState.subscribe((settings) => { + unsubSettings = settingsState.subscribe((settings) => { if (view) { view.dispatch({ effects: editorTheme.reconfigure( @@ -85,6 +86,7 @@ }); onDestroy(() => { + unsubSettings?.(); if (view) { view.destroy(); } diff --git a/src/interface/components/ColourPicker.svelte b/src/interface/components/ColourPicker.svelte index 34468642..11e7a335 100644 --- a/src/interface/components/ColourPicker.svelte +++ b/src/interface/components/ColourPicker.svelte @@ -90,7 +90,7 @@ bind:this={background} class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30" onclick={handleBackgroundClick} - onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }} + onkeydown={(e) => { if (e.key === 'Enter') handleBackgroundClick(e as unknown as MouseEvent) }} >
{ isRecording = true; - recordedKeys.clear(); + recordedKeys = new Set(); inputElement?.focus(); }; @@ -87,7 +87,7 @@ if (recordedKeys.has('esc')) { onChange(''); isRecording = false; - recordedKeys.clear(); + recordedKeys = new Set(); inputElement?.blur(); return; } @@ -113,10 +113,16 @@ } isRecording = false; - recordedKeys.clear(); + recordedKeys = new Set(); inputElement?.blur(); }; + const addRecordedKey = (key: string) => { + const next = new Set(recordedKeys); + next.add(key); + recordedKeys = next; + }; + const handleKeyDown = (e: KeyboardEvent) => { if (!isRecording) return; @@ -126,14 +132,14 @@ const key = formatKeyForHotkey(e.key); // Add modifiers - if (e.ctrlKey) recordedKeys.add('ctrl'); - if (e.metaKey) recordedKeys.add('cmd'); - if (e.altKey) recordedKeys.add('alt'); - if (e.shiftKey) recordedKeys.add('shift'); + if (e.ctrlKey) addRecordedKey('ctrl'); + if (e.metaKey) addRecordedKey('cmd'); + if (e.altKey) addRecordedKey('alt'); + if (e.shiftKey) addRecordedKey('shift'); // Add the main key (ignore modifier keys themselves) if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) { - recordedKeys.add(key); + addRecordedKey(key); } // Auto-stop recording if we have a main key diff --git a/src/interface/components/Slider.svelte b/src/interface/components/Slider.svelte index 7af9a793..c4a69d8c 100644 --- a/src/interface/components/Slider.svelte +++ b/src/interface/components/Slider.svelte @@ -1,5 +1,5 @@
-
+
{#each tabs as { title }, index}