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.
This commit is contained in:
StroepWafel
2025-11-29 19:47:30 +10:30
parent fd86e57442
commit 2c077bc755
6 changed files with 193 additions and 26 deletions
+32
View File
@@ -2,6 +2,34 @@ 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";
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() { function reloadSeqtaPages() {
const result = browser.tabs.query({}); const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
@@ -56,6 +84,10 @@ browser.runtime.onMessage.addListener(
fetchNews(request.source ?? "australia", sendResponse); fetchNews(request.source ?? "australia", sendResponse);
return true; return true;
case "fetchPrivacyPolicy":
fetchPrivacyPolicy(sendResponse);
return true;
default: default:
console.log("Unknown request type"); console.log("Unknown request type");
} }
+4
View File
@@ -0,0 +1,4 @@
{
"last_updated": "2024-06-15T12:00:00Z",
"whatsnew_html": "<div class=\"whatsnewTextContainer\" style=\"overflow-y: auto; font-size: 1.3rem; line-height: 1.6;\"><p>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.</p><p>To view our privacy policy, please click the <strong>shield icon</strong> in the settings&nbsp;menu, or <a href=\"https://betterseqta.org/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" id=\"privacy-link\" style=\"color: inherit; text-decoration: underline; cursor: pointer; white-space: nowrap;\">click here</a>.</p><p style=\"font-weight: bold; margin-top: 15px;\">We never collect any information from you, and aim to provide the best features possible.</p></div>"
}
@@ -10,6 +10,8 @@
import type { SettingsList } from "@/interface/types/SettingsProps" import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" 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 { 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"
@@ -340,6 +342,25 @@
/> />
</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">Show Privacy Notification</h2>
<p class="text-xs">Show the privacy notification popup on next page load</p>
</div>
<div>
<Button
onClick={async () => {
settingsState.privacyStatementShown = false;
settingsState.privacyStatementLastUpdated = undefined;
closeExtensionPopup();
// Small delay to ensure popup is closed before showing notification
await new Promise(resolve => setTimeout(resolve, 100));
await checkAndShowPrivacyNotification();
}}
text="Show Now"
/>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
+5 -5
View File
@@ -24,7 +24,7 @@ import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; 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"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -94,10 +94,10 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err); console.error("Error during loading cleanup:", err);
} }
// Show privacy statement notification on first open of this version (before what's new) // Check and show privacy statement notification (before what's new)
if (!settingsState.privacyStatementShown && !document.getElementById("whatsnewbk")) { // This will check the API and compare timestamps
showPrivacyNotification(); if (!document.getElementById("privacy-notification")) {
settingsState.privacyStatementShown = true; await checkAndShowPrivacyNotification();
} }
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) { if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
@@ -2,8 +2,99 @@ import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { animate } from "motion"; import { animate } from "motion";
import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup"; 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<PrivacyPolicyResponse | null> {
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; if (document.getElementById("privacy-notification")) return;
const popupBackground = document.createElement("div"); const popupBackground = document.createElement("div");
@@ -25,7 +116,14 @@ export function showPrivacyNotification() {
</div>`, </div>`,
).firstChild as HTMLElement; ).firstChild as HTMLElement;
const text = stringToHTML(/* html */ ` // 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 */ `
<div class="whatsnewTextContainer" style="overflow-y: auto; font-size: 1.3rem; line-height: 1.6;"> <div class="whatsnewTextContainer" style="overflow-y: auto; font-size: 1.3rem; line-height: 1.6;">
<p> <p>
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. 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.
@@ -38,9 +136,10 @@ export function showPrivacyNotification() {
</p> </p>
</div> </div>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
}
if (header) container.append(header); if (header) container.append(header);
container.append(text); if (text) container.append(text);
const closeButton = document.createElement("div"); const closeButton = document.createElement("div");
closeButton.id = "whatsnewclosebutton"; 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(() => { setTimeout(() => {
const privacyLink = document.getElementById("privacy-link"); // Find all links that point to the privacy policy
if (privacyLink) { const allLinks = container.querySelectorAll('a[href*="betterseqta.org/privacy"]');
privacyLink.addEventListener("click", (e) => { allLinks.forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault(); 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); }, 100);
} }
// Legacy function name for backwards compatibility
export function showPrivacyNotification() {
checkAndShowPrivacyNotification();
}
+1
View File
@@ -31,6 +31,7 @@ export interface SettingsState {
transparencyEffects: boolean; transparencyEffects: boolean;
justupdated?: boolean; justupdated?: boolean;
privacyStatementShown?: boolean; privacyStatementShown?: boolean;
privacyStatementLastUpdated?: string;
timeFormat?: string; timeFormat?: string;
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;