From 2c077bc755fae93a3760796e8979bd85dfc2ae5b Mon Sep 17 00:00:00 2001 From: StroepWafel <109832156+StroepWafel@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:47:30 +1030 Subject: [PATCH] Add dynamic privacy policy notification with API fetch Implements fetching the privacy policy from the BetterSEQTA+ API and displays a notification if the policy has been updated. Adds sanitization for HTML content, updates settings state to track last shown timestamp, and provides a manual trigger in settings. Refactors notification logic for improved security and maintainability. --- src/background.ts | 32 ++++ src/exampleprivacyresponse.json | 4 + src/interface/pages/settings/general.svelte | 21 +++ src/plugins/monofile.ts | 10 +- .../utils/Openers/OpenPrivacyNotification.ts | 151 +++++++++++++++--- src/types/storage.ts | 1 + 6 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 src/exampleprivacyresponse.json diff --git a/src/background.ts b/src/background.ts index 414d823a..a0cf9411 100644 --- a/src/background.ts +++ b/src/background.ts @@ -2,6 +2,34 @@ import browser from "webextension-polyfill"; import type { SettingsState } from "@/types/storage"; import { fetchNews } from "./background/news"; +interface PrivacyPolicyResponse { + last_updated: string; + whatsnew_html: string; +} + +async function fetchPrivacyPolicy(sendResponse: (response?: any) => void) { + try { + const response = await fetch("https://betterseqta.org/api/policy/privacy", { + method: "GET", + headers: { + "Accept": "application/json", + }, + }); + + if (!response.ok) { + console.error("[BetterSEQTA+] Failed to fetch privacy policy:", response.status); + sendResponse({ error: `HTTP ${response.status}`, data: null }); + return; + } + + const data = await response.json() as PrivacyPolicyResponse; + sendResponse({ error: null, data }); + } catch (error) { + console.error("[BetterSEQTA+] Error fetching privacy policy:", error); + sendResponse({ error: String(error), data: null }); + } +} + function reloadSeqtaPages() { const result = browser.tabs.query({}); function open(tabs: any) { @@ -56,6 +84,10 @@ browser.runtime.onMessage.addListener( fetchNews(request.source ?? "australia", sendResponse); return true; + case "fetchPrivacyPolicy": + fetchPrivacyPolicy(sendResponse); + return true; + default: console.log("Unknown request type"); } diff --git a/src/exampleprivacyresponse.json b/src/exampleprivacyresponse.json new file mode 100644 index 00000000..e51df1e0 --- /dev/null +++ b/src/exampleprivacyresponse.json @@ -0,0 +1,4 @@ +{ + "last_updated": "2024-06-15T12:00:00Z", + "whatsnew_html": "

It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation.

To view our privacy policy, please click the shield icon in the settings menu, or click here.

We never collect any information from you, and aim to provide the best features possible.

" +} \ No newline at end of file diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 8557b7be..0628898d 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -10,6 +10,8 @@ import type { SettingsList } from "@/interface/types/SettingsProps" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import PickerSwatch from "@/interface/components/PickerSwatch.svelte" + import { checkAndShowPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" + import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { getAllPluginSettings } from "@/plugins" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" @@ -340,6 +342,25 @@ /> +
+
+

Show Privacy Notification

+

Show the privacy notification popup on next page load

+
+
+
+
{/if} diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 93b3b15a..c2006173 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -24,7 +24,7 @@ import loading from "@/seqta/ui/Loading"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; -import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"; +import { checkAndShowPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; @@ -94,10 +94,10 @@ export async function finishLoad() { console.error("Error during loading cleanup:", err); } - // Show privacy statement notification on first open of this version (before what's new) - if (!settingsState.privacyStatementShown && !document.getElementById("whatsnewbk")) { - showPrivacyNotification(); - settingsState.privacyStatementShown = true; + // Check and show privacy statement notification (before what's new) + // This will check the API and compare timestamps + if (!document.getElementById("privacy-notification")) { + await checkAndShowPrivacyNotification(); } if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) { diff --git a/src/seqta/utils/Openers/OpenPrivacyNotification.ts b/src/seqta/utils/Openers/OpenPrivacyNotification.ts index b2275ace..0fc8e56d 100644 --- a/src/seqta/utils/Openers/OpenPrivacyNotification.ts +++ b/src/seqta/utils/Openers/OpenPrivacyNotification.ts @@ -2,8 +2,99 @@ import stringToHTML from "../stringToHTML"; import { settingsState } from "../listeners/SettingsState"; import { animate } from "motion"; import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup"; +import DOMPurify from "dompurify"; +import browser from "webextension-polyfill"; -export function showPrivacyNotification() { +interface PrivacyPolicyResponse { + last_updated: string; + whatsnew_html: string; +} + +async function fetchPrivacyPolicy(): Promise { + try { + // Use background script to avoid CORS issues + const response = await browser.runtime.sendMessage({ type: "fetchPrivacyPolicy" }) as { error: string | null; data: PrivacyPolicyResponse | null }; + + if (response.error) { + console.error("[BetterSEQTA+] Failed to fetch privacy policy:", response.error); + return null; + } + + return response.data; + } catch (error) { + console.error("[BetterSEQTA+] Error fetching privacy policy:", error); + return null; + } +} + +export async function checkAndShowPrivacyNotification() { + if (document.getElementById("privacy-notification")) return; + + // Fetch the privacy policy from the API + const policyData = await fetchPrivacyPolicy(); + + if (!policyData) { + // If API fails, fall back to showing the notification if never shown + if (!settingsState.privacyStatementShown) { + showPrivacyNotificationWithContent(null); + settingsState.privacyStatementShown = true; + } + return; + } + + // Check if we should show the notification + const storedTimestamp = settingsState.privacyStatementLastUpdated; + const shouldShow = !storedTimestamp || + new Date(policyData.last_updated) > new Date(storedTimestamp); + + if (shouldShow) { + // Sanitize the HTML content to prevent XSS attacks + // DOMPurify will remove any dangerous scripts, event handlers, etc + // ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + // SANITIZE CONTENT: + // ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + // The content in sanitizedHTML is the only content that is allowed to be displayed in the notification: + const sanitizedHTML = DOMPurify.sanitize(policyData.whatsnew_html, { + ALLOWED_TAGS: ['div', 'p', 'strong', 'a', 'h1', 'h2', 'h3', 'ul', 'li', 'span', 'em', 'b', 'i'], + ALLOWED_ATTR: ['class', 'style', 'href', 'target', 'rel', 'id'], + ALLOW_DATA_ATTR: false, + ALLOW_UNKNOWN_PROTOCOLS: false, + // Ensure links are safe - allow https/http/mailto only + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + // Force all external links to have target="_blank" and rel="noopener noreferrer" + ADD_ATTR: ['target', 'rel'], + }); + + // Post-process to sanitize URLs and ensure all links have proper attributes + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = sanitizedHTML; + + // First, remove hrefs from links that aren't to betterseqta.org + tempDiv.querySelectorAll("a").forEach(a => { + const href = a.getAttribute("href") || ""; + // Allow only links to your domain + if (!href.startsWith("https://betterseqta.org")) { + a.removeAttribute("href"); // neuter link + a.style.textDecoration = "none"; // optional visual fix + a.style.cursor = "default"; // optional + } else { + // Ensure all betterseqta.org links have proper attributes + (a as HTMLAnchorElement).target = "_blank"; + (a as HTMLAnchorElement).rel = "noopener noreferrer"; + } + }); + + const cleanHTML = tempDiv.innerHTML; + + showPrivacyNotificationWithContent(cleanHTML); + + // Update the stored timestamp + settingsState.privacyStatementLastUpdated = policyData.last_updated; + settingsState.privacyStatementShown = true; + } +} + +function showPrivacyNotificationWithContent(htmlContent: string | null) { if (document.getElementById("privacy-notification")) return; const popupBackground = document.createElement("div"); @@ -25,22 +116,30 @@ export function showPrivacyNotification() { `, ).firstChild as HTMLElement; - const text = stringToHTML(/* html */ ` -
-

- It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation. -

-

- To view our privacy policy, please click the shield icon in the settings menu, or click here. -

-

- We never collect any information from you, and aim to provide the best features possible. -

-
- `).firstChild as HTMLElement; + // Use fetched content if available, otherwise use fallback + let text: HTMLElement; + if (htmlContent) { + // Parse the sanitized HTML + text = stringToHTML(htmlContent).firstChild as HTMLElement; + } else { + // Fallback content if API fails + text = stringToHTML(/* html */ ` +
+

+ It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation. +

+

+ To view our privacy policy, please click the shield icon in the settings menu, or click here. +

+

+ We never collect any information from you, and aim to provide the best features possible. +

+
+ `).firstChild as HTMLElement; + } if (header) container.append(header); - container.append(text); + if (text) container.append(text); const closeButton = document.createElement("div"); closeButton.id = "whatsnewclosebutton"; @@ -71,15 +170,25 @@ export function showPrivacyNotification() { } }); - // Handle privacy link click - ensure it opens in new tab + // Handle all privacy policy links - ensure they open in new tab setTimeout(() => { - const privacyLink = document.getElementById("privacy-link"); - if (privacyLink) { - privacyLink.addEventListener("click", (e) => { + // Find all links that point to the privacy policy + const allLinks = container.querySelectorAll('a[href*="betterseqta.org/privacy"]'); + allLinks.forEach((link) => { + link.addEventListener("click", (e) => { e.preventDefault(); - window.open("https://betterseqta.org/privacy", "_blank", "noopener,noreferrer"); + const href = (link as HTMLAnchorElement).href || "https://betterseqta.org/privacy"; + window.open(href, "_blank", "noopener,noreferrer"); }); - } + // Ensure target and rel attributes are set + (link as HTMLAnchorElement).target = "_blank"; + (link as HTMLAnchorElement).rel = "noopener noreferrer"; + }); }, 100); } +// Legacy function name for backwards compatibility +export function showPrivacyNotification() { + checkAndShowPrivacyNotification(); +} + diff --git a/src/types/storage.ts b/src/types/storage.ts index 5b6e7c1d..d25cc192 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -31,6 +31,7 @@ export interface SettingsState { transparencyEffects: boolean; justupdated?: boolean; privacyStatementShown?: boolean; + privacyStatementLastUpdated?: string; timeFormat?: string; animations: boolean; defaultPage: string;