Compare commits

...

14 Commits

Author SHA1 Message Date
AdenMGB 6c08bea5f7 feat: start secuirity thingo 2026-05-08 17:18:23 +09:30
AdenMGB 01e679eab6 Revert "fix: add some better detection logic for assements widget #429"
This reverts commit 01cd5d1428.
2026-05-06 17:31:41 +09:30
Aden Lindsay f57bd107b9 Merge pull request #436 from Jaxx7594/asessment-average-manual-input
Feat: ability to manually input weightings for assessments
2026-05-05 19:51:00 +09:30
Jaxon Lewis-Wilson aa5d193e55 assessmentsAverage: Fix inaccurate weight when a weight == N/A
N/A weights were automatically set to a weight of 1 for some reason. I removed it from the calculations completely with this commit.
2026-05-05 18:14:06 +08:00
Jaxon Lewis-Wilson da5bc7ab11 assessmentsAverage: Fix weight display upon setting override 2026-05-05 18:10:13 +08:00
Jaxon Lewis-Wilson b0857054eb assessmentsAverage: Fix unmarked/upcoming assessment indexing and weight display 2026-05-05 17:56:06 +08:00
Jaxon Lewis-Wilson f721bf6609 Revert "feat: dont inject weightings page in assements without results"
This reverts commit 2aecd63850.
Reverting so that I can solve the indexing issue. Only marked assessments are getting indexed, which is incorrect behaviour that slipped testing when the plugin was first made.
2026-05-05 16:32:12 +08:00
AdenMGB 2aecd63850 feat: dont inject weightings page in assements without results 2026-05-05 17:44:58 +09:30
Jaxx7594 f35520029f assessmentAverage: Remove remnant comment 2026-05-04 22:53:05 +08:00
Jaxx7594 95994fcd3a Merge branch 'main' into asessment-average-manual-input 2026-05-04 22:46:29 +08:00
Jaxon Lewis-Wilson 999f12e958 assessmentsAverage: Add changes to changelog 2026-05-04 22:39:53 +08:00
Jaxon Lewis-Wilson 260afac294 assessmentsAverage: Fix display of missing weighting, and minor change to override section. 2026-05-04 18:36:16 +08:00
Jaxon Lewis-Wilson 678a958351 assessmentsAverage: Add ability to override/set weighting per assessment. 2026-05-04 18:32:32 +08:00
Jaxon Lewis-Wilson f7d9199500 assessmentsAverage: Minor WEIGHT label styling fixes 2026-04-27 00:28:51 +08:00
14 changed files with 1461 additions and 203 deletions
+7 -2
View File
@@ -19,7 +19,8 @@
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b",
"zip": "bedframe zip"
"zip": "bedframe zip",
"test": "vitest run"
},
"targets": {
"prod": {
@@ -40,6 +41,7 @@
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/jsdom": "^28.0.1",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -47,6 +49,7 @@
"dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0",
"glob": "^11.0.1",
"jsdom": "^29.1.1",
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
"process": "^0.11.10",
@@ -55,7 +58,8 @@
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
"tailwindcss": "3",
"url": "^0.11.4"
"url": "^0.11.4",
"vitest": "^4.1.5"
},
"dependencies": {
"@bedframe/core": "^0.1.0",
@@ -92,6 +96,7 @@
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"jspdf": "^4.2.1",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mathjs": "^14.4.0",
@@ -7,11 +7,11 @@ import {
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
injectWeightingsTab,
letterToNumber,
parseAssessments,
processAssessments,
@@ -20,6 +20,7 @@ import {
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
}
const settings = defineSettings({
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
name: "Assessment Averages",
@@ -58,17 +61,56 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
);
await parseAssessments(api);
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
});
},
};
let renderInFlight = false;
async function renderSubjectAverage(api: any) {
if (renderInFlight) return;
renderInFlight = true;
try {
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
// Remove existing subject average before re-rendering
Array.from(
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
)
.find((el) => el.textContent === "Subject Average")
?.closest("[class*='AssessmentItem__AssessmentItem___']")
?.remove();
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return;
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
@@ -86,35 +128,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
"AssessmentItem__title___",
);
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
@@ -128,9 +141,25 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
@@ -140,17 +169,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
@@ -159,7 +179,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
@@ -180,21 +199,11 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
`).firstChild!,
assessmentsList.firstChild,
);
applySubjectColourToOverallResult();
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
} finally {
renderInFlight = false;
}
}
});
},
};
function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
+358 -49
View File
@@ -17,6 +17,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) {
api.storage.assessments = {};
}
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
}
export function clearStuck(api: any) {
@@ -79,52 +82,84 @@ function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement | null;
if (
!statsContainer ||
statsContainer.querySelector(".betterseqta-weight-label")
)
return;
if (!statsContainer) {
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer = document.createElement("div");
statsContainer.className = statsClass;
statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
const label = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement;
if (!label) return;
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label");
const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
);
statsContainer.style.justifyContent = hasNativeLabel
? "space-between"
: "flex-end";
const displayText =
weighting && weighting !== "processing" && weighting !== "N/A"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
const textNodes = Array.from(existingLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) textNodes[0].textContent = displayText;
return;
}
statsContainer.style.display = "flex";
statsContainer.style.alignItems = "center";
statsContainer.style.width = "100%";
// Try to clone an existing label from the stats container first,
// fall back to building from scratch if none exists
const existingNativeLabel = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement | null;
const weightLabel = existingNativeLabel
? (existingNativeLabel.cloneNode(true) as HTMLElement)
: (() => {
const labelClass = getClassByPattern(document, "Label__Label___");
const innerTextClass = getClassByPattern(document, "Label__innerText___");
const el = document.createElement("label");
el.className = labelClass;
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
return el;
})();
weightLabel.classList.add("betterseqta-weight-label");
weightLabel.style.flex = "none";
weightLabel.style.width = "fit-content";
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) {
textNodes[0].textContent =
weighting && weighting !== "processing"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
textNodes[0].textContent = displayText;
} else {
weightLabel.appendChild(document.createTextNode(displayText));
}
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
statsContainer.style.display = "flex";
statsContainer.style.flexDirection = "column";
statsContainer.style.alignItems = "flex-end";
statsContainer.style.gap = "2px";
statsContainer.style.justifyContent = "center";
weightLabel.style.position = "relative";
weightLabel.style.inset = "unset";
weightLabel.style.transform = "none";
statsContainer.appendChild(weightLabel);
}
@@ -228,7 +263,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
@@ -519,7 +555,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -532,15 +572,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0;
for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`,
);
@@ -550,12 +581,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if (
weighting === null ||
weighting === undefined ||
@@ -563,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
continue
} else {
const weight = parseFloat(weighting);
@@ -587,3 +628,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count,
};
}
function resolveTabSetClasses(): Record<string, string> {
const patterns = [
"TabSet__tabsheet___",
"TabSet__hidden___",
"TabSet__selected___",
"TabSet__disappearToLeft___",
"TabSet__disappearToRight___",
"TabSet__appearFromRight___",
"TabSet__appearFromLeft___",
];
const resolved: Record<string, string> = {};
// First pass: scan live DOM elements (fast, covers currently-applied classes)
const allClasses = Array.from(
document.querySelectorAll('[class*="TabSet__"]'),
).flatMap((el) => Array.from(el.classList));
for (const pattern of patterns) {
const found = allClasses.find((c) => c.startsWith(pattern));
if (found) resolved[pattern] = found;
}
// Second pass: scan stylesheets for any classes not yet in the DOM
// (e.g. animation classes that haven't been applied yet)
const missing = patterns.filter((p) => !resolved[p]);
if (missing.length > 0) {
try {
for (const sheet of Array.from(document.styleSheets)) {
if (missing.every((p) => resolved[p])) break;
try {
for (const rule of Array.from(sheet.cssRules ?? [])) {
if (!(rule instanceof CSSStyleRule)) continue;
const selectorClasses =
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
for (const pattern of missing) {
if (!resolved[pattern]) {
const match = selectorClasses.find((c) =>
c.slice(1).startsWith(pattern),
);
if (match) resolved[pattern] = match.slice(1);
}
}
}
} catch {
// Cross-origin stylesheet
}
}
} catch {}
}
// Fallback: use the base pattern as-is so the function doesn't crash,
// though styles won't apply if the hash is truly unknown.
for (const pattern of patterns) {
if (!resolved[pattern]) resolved[pattern] = pattern;
}
return resolved;
}
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const statusNote = !assessmentID
? ""
: rawWeight === "processing"
? "Weighting is still being detected."
: weightingUnavailable
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
opacity: 0.4;
}
</style>
<div style="padding:16px;max-width:360px">
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
Set the weighting for this assessment.
${statusNote}
</p>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">Override %</label>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${autoWeight ?? ""}"
value="${override ?? ""}"
${!assessmentID ? "disabled" : ""}
style="
width:90px;
padding:5px 8px;
border-radius:6px;
border:1px solid rgba(128,128,128,0.3);
background:rgba(128,128,128,0.08);
color:inherit;
font-size:13px;
outline:none;
"
/>
</div>
<div style="margin-top:10px;min-height:18px">
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
</div>
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
</div>
`;
if (!assessmentID) return;
const input = sheet.querySelector(
"#betterseqta-weight-override",
) as HTMLInputElement;
const statusEl = sheet.querySelector(
".betterseqta-save-status",
) as HTMLElement;
const save = () => {
const raw = input.value.trim();
if (raw === "") {
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
} else {
const val = parseFloat(raw);
if (isNaN(val) || val < 0) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = "Invalid. Must be 0 or greater";
statusEl.style.color = "rgba(255,80,80,0.8)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
input.blur();
save();
}
});
input.addEventListener("input", () => {
input.style.borderColor = "rgba(128,128,128,0.3)";
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
statusEl.textContent = "";
});
}
export function injectWeightingsTab(api: any) {
const tabList = document.querySelector(
'[class*="TabSet__tabs___"]',
) as HTMLElement;
const container = document.querySelector(
'[class*="TabSet__tabContainer___"]',
) as HTMLElement;
if (!tabList || !container) return;
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
const cls = resolveTabSetClasses();
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
/-tab-\d+$/,
"",
);
const newIndex = tabList.querySelectorAll("li").length;
const newTab = document.createElement("li");
newTab.id = `${prefix}-tab-${newIndex}`;
newTab.className = "";
newTab.setAttribute("aria-selected", "false");
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
newTab.classList.add("betterseqta-weightings-tab");
newTab.textContent = "Weightings";
tabList.appendChild(newTab);
const newSheet = document.createElement("div");
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
newSheet.className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
cls["TabSet__disappearToRight___"],
].join(" ");
container.appendChild(newSheet);
let populated = false;
newTab.addEventListener("click", () => {
if (!populated) {
buildWeightingsTabContent(api, newSheet);
populated = true;
}
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
const allSheets = Array.from(
container.querySelectorAll('[class*="tabsheet"]'),
);
allTabs.forEach((tab, i) => {
tab.addEventListener("click", () => {
const currentIndex = allTabs.findIndex((t) =>
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
allSheets[i].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__selected___"],
goingRight
? cls["TabSet__appearFromRight___"]
: cls["TabSet__appearFromLeft___"],
].join(" ");
tab.className = cls["TabSet__selected___"];
tab.setAttribute("aria-selected", "true");
});
});
}
@@ -28,17 +28,9 @@ async function fetchJSON(url: string, body: any) {
async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
const activeGroup = res.payload.find((s: any) => s.active === 1);
const activeYear = activeGroup?.year;
const allSubjects = res.payload
.filter((s: any) => s.year === activeYear)
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
const seen = new Set<string>();
return allSubjects.filter((s: Subject) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
}
async function loadPrefs(student: number) {
@@ -0,0 +1,246 @@
/**
* BetterSEQTA Security — core XSS-focused protections for HTML rendered from SEQTA APIs.
*
* Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`.
* Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"`
* (without `allow-scripts`) on those iframes so script execution is suppressed while we can
* still read `contentDocument` for scanning and existing theme/CSS injection.
*
* The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so
* React replacing `.uiFrameWrapper` / iframe siblings does not destroy it.
*/
import type { Plugin } from "../../core/types";
import {
analyzeHtmlThreats,
type ThreatAnalysis,
} from "@/seqta/security/analyzeHtmlThreats";
import {
mountBlockedContentUi,
SECURITY_MESSAGE_OVERLAY_CLASS,
} from "@/seqta/security/blockedContentUi";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe";
const userHtmlIframeLoadHooked = new WeakSet<HTMLIFrameElement>();
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => void>();
function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void {
const fn = messageOverlayCleanups.get(iframe);
if (fn) {
fn();
messageOverlayCleanups.delete(iframe);
}
}
function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void {
if (iframe.dataset.bssUserHtmlSandbox === "1") return;
iframe.dataset.bssUserHtmlSandbox = "1";
iframe.setAttribute("sandbox", "allow-same-origin");
}
function wipeIframeDocument(iframe: HTMLIFrameElement): void {
try {
const d = iframe.contentDocument;
if (!d) return;
d.open();
d.write(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
);
d.close();
} catch {
/* ignore */
}
}
/**
* After we replace a malicious document, the iframe fires `load` again with this blank shell.
* That pass must not tear down the blocker UI or the iframe would “recover” for one frame.
*/
function isPostWipeBlankDocument(doc: Document): boolean {
const body = doc.body;
if (!body || body.childElementCount > 0) return false;
if ((body.textContent ?? "").trim().length > 0) return false;
const meta = doc.head?.querySelector('meta[charset="utf-8"]');
if (!meta) return false;
return doc.documentElement.outerHTML.length < 800;
}
/**
* Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe
* markup without removing this node.
*/
function mountBodyAnchoredMessageOverlay(
iframe: HTMLIFrameElement,
anchor: HTMLElement,
opts: {
analysis: ThreatAnalysis;
rawSnippet: string;
contextTitle?: string;
},
): void {
teardownMessageSecurityOverlay(iframe);
const shell = document.createElement("div");
shell.className = SECURITY_MESSAGE_OVERLAY_CLASS;
Object.assign(shell.style, {
position: "fixed",
zIndex: "2147483646",
overflow: "hidden",
pointerEvents: "auto",
boxSizing: "border-box",
padding: "12px",
background: "rgba(24,24,27,0.35)",
});
const inner = document.createElement("div");
Object.assign(inner.style, {
width: "100%",
height: "100%",
boxSizing: "border-box",
});
shell.appendChild(inner);
let raf = 0;
const syncRect = (): void => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
if (!iframe.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
if (!anchor.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
const r = anchor.getBoundingClientRect();
const pad = 10;
const left = Math.max(8, r.left - pad);
const top = Math.max(8, r.top - pad);
const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2);
const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2);
shell.style.left = `${left}px`;
shell.style.top = `${top}px`;
shell.style.width = `${Math.max(0, width)}px`;
shell.style.height = `${Math.max(0, height)}px`;
});
};
syncRect();
document.body.appendChild(shell);
const ro = new ResizeObserver(syncRect);
ro.observe(anchor);
window.addEventListener("resize", syncRect);
window.addEventListener("scroll", syncRect, true);
const unmountPanel = mountBlockedContentUi(inner, {
surface: "message",
analysis: opts.analysis,
rawSnippet: opts.rawSnippet,
contextTitle: opts.contextTitle,
rootOverlay: true,
});
const cleanup = (): void => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("resize", syncRect);
window.removeEventListener("scroll", syncRect, true);
unmountPanel();
shell.remove();
};
messageOverlayCleanups.set(iframe, cleanup);
}
function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void {
let idoc: Document | null = null;
try {
idoc = iframe.contentDocument;
} catch {
return;
}
if (!idoc?.documentElement) return;
const wrapper =
iframe.closest(".uiFrameWrapper") ??
iframe.closest(".iframeWrapper") ??
iframe.parentElement;
if (!wrapper) return;
if (
iframe.dataset.bssAwaitingWipeLoad === "1" &&
isPostWipeBlankDocument(idoc)
) {
iframe.dataset.bssAwaitingWipeLoad = "";
return;
}
iframe.dataset.bssAwaitingWipeLoad = "";
teardownMessageSecurityOverlay(iframe);
iframe.style.visibility = "";
iframe.style.height = "";
iframe.style.minHeight = "";
const html = idoc.documentElement.outerHTML;
const analysis = analyzeHtmlThreats(html);
if (!analysis.blocked) return;
const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]');
const anchor = (pane ?? wrapper) as HTMLElement;
iframe.dataset.bssAwaitingWipeLoad = "1";
wipeIframeDocument(iframe);
iframe.style.visibility = "hidden";
iframe.style.height = "0";
iframe.style.minHeight = "0";
const subject = pane
?.querySelector('[class*="Message__subject___"]')
?.textContent?.trim();
mountBodyAnchoredMessageOverlay(iframe, anchor, {
analysis,
rawSnippet: html.slice(0, 50_000),
contextTitle: subject,
});
}
const betterSeqtaSecurityPlugin: Plugin = {
id: "better-seqta-security",
name: "BetterSEQTA Security",
description:
"Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.",
version: "1.0.0",
settings: {},
run: () => {
const { unregister } = eventManager.register(
USER_HTML_IFRAME_EVENT,
{
elementType: "iframe",
customCheck: (element) => element.classList.contains("userHTML"),
},
(element) => {
const iframe = element as HTMLIFrameElement;
if (userHtmlIframeLoadHooked.has(iframe)) return;
userHtmlIframeLoadHooked.add(iframe);
applyMessageIframeSandbox(iframe);
const onLoad = () => handleUserHtmlIframeLoaded(iframe);
iframe.addEventListener("load", onLoad);
queueMicrotask(onLoad);
},
);
return unregister;
},
};
export default betterSeqtaSecurityPlugin;
+2
View File
@@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic";
import betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity";
//import messageFoldersPlugin from "./built-in/messageFolders";
//import testPlugin from './built-in/test';
@@ -21,6 +22,7 @@ const pluginManager = PluginManager.getInstance();
// Register built-in plugins
pluginManager.registerPlugin(themesPlugin);
pluginManager.registerPlugin(betterSeqtaSecurityPlugin);
pluginManager.registerPlugin(animatedBackgroundPlugin);
pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin);
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { analyzeHtmlThreats } from "./analyzeHtmlThreats";
describe("analyzeHtmlThreats", () => {
it("does not flag benign HTML", () => {
const r = analyzeHtmlThreats("<p>Hello <strong>world</strong></p>");
expect(r.blocked).toBe(false);
expect(r.findings).toHaveLength(0);
});
it("flags script tags", () => {
const r = analyzeHtmlThreats('<p>x</p><script>alert(1)</script>');
expect(r.blocked).toBe(true);
expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true);
});
it("flags javascript: URLs", () => {
const r = analyzeHtmlThreats('<a href="javascript:void(0)">click</a>');
expect(r.blocked).toBe(true);
expect(r.findings.some((f) => f.kind === "dangerous_url_scheme")).toBe(
true,
);
});
it("flags inline event handlers", () => {
const r = analyzeHtmlThreats('<img src="https://example.com/x.png" onerror="alert(1)">');
expect(r.blocked).toBe(true);
expect(
r.findings.some((f) => f.kind === "inline_event_handler"),
).toBe(true);
});
it("allows data:image/png sources", () => {
const r = analyzeHtmlThreats(
'<img alt="i" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==">',
);
expect(r.blocked).toBe(false);
});
it("flags data:text/html", () => {
const r = analyzeHtmlThreats(
'<iframe src="data:text/html,%3Cscript%3E%3C/script%3E"></iframe>',
);
expect(r.blocked).toBe(true);
});
});
+190
View File
@@ -0,0 +1,190 @@
import DOMPurify from "dompurify";
export interface ThreatFinding {
kind: string;
detail: string;
}
export interface ThreatAnalysis {
blocked: boolean;
findings: ThreatFinding[];
}
const INLINE_HANDLER_RE = /^on[a-z]+$/i;
const DANGEROUS_SCHEME_RE =
/^\s*(javascript|vbscript|about\s*:|file\s*:)/i;
/** Inline data URIs except common raster images (emails often embed PNG/JPEG). */
function isDangerousDataUri(url: string): boolean {
const v = url.trim().toLowerCase();
if (!v.startsWith("data:")) return false;
if (/^data:image\/(png|jpe?g|gif|webp|bmp)([;,]|$)/i.test(v)) return false;
return true;
}
/** Patterns inside executable contexts (script bodies). */
const SCRIPT_TEXT_SUSPICIOUS =
/\beval\s*\(|new\s+Function\s*\(|document\s*\.\s*write|\.execScript\s*\(/i;
function addFinding(
findings: ThreatFinding[],
kind: string,
detail: string,
): void {
if (findings.some((f) => f.kind === kind && f.detail === detail)) return;
findings.push({ kind, detail });
}
function inspectUrlAttr(attrName: string, value: string): ThreatFinding[] {
const out: ThreatFinding[] = [];
const v = value.trim();
if (!v) return out;
if (DANGEROUS_SCHEME_RE.test(v) || isDangerousDataUri(v)) {
out.push({
kind: "dangerous_url_scheme",
detail: `${attrName}="${v.slice(0, 120)}${v.length > 120 ? "…" : ""}"`,
});
}
return out;
}
function walkElement(el: Element, findings: ThreatFinding[]): void {
const tag = el.tagName.toLowerCase();
if (tag === "script") {
const src = el.getAttribute("src")?.trim() ?? "";
if (
src &&
(DANGEROUS_SCHEME_RE.test(src) || isDangerousDataUri(src))
) {
addFinding(findings, "script_src", `script src="${src.slice(0, 160)}"`);
} else if (!src && el.textContent && SCRIPT_TEXT_SUSPICIOUS.test(el.textContent)) {
addFinding(
findings,
"script_pattern",
"Inline script contains suspicious patterns (eval/new Function/document.write).",
);
} else {
addFinding(findings, "script_tag", "A script element is present in HTML.");
}
return;
}
if (tag === "meta") {
const httpEquiv = el.getAttribute("http-equiv")?.toLowerCase();
if (httpEquiv === "refresh") {
addFinding(
findings,
"meta_refresh",
'meta http-equiv="refresh" can redirect or execute unexpectedly.',
);
}
}
if (tag === "iframe" || tag === "frame") {
const src = el.getAttribute("src")?.trim() ?? "";
const srcdoc = el.getAttribute("srcdoc") ?? "";
findings.push(...inspectUrlAttr("iframe[src]", src));
if (srcdoc.length > 0) {
addFinding(
findings,
"iframe_srcdoc",
"iframe srcdoc may embed arbitrary markup; nested analysis follows.",
);
nestedAnalyze(srcdoc, findings, 2);
}
}
if (tag === "object" || tag === "embed") {
const url =
el.getAttribute("data") ?? el.getAttribute("src") ?? "";
findings.push(...inspectUrlAttr(`${tag}[src/data]`, url));
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name;
if (INLINE_HANDLER_RE.test(name)) {
addFinding(
findings,
"inline_event_handler",
`${tag}.${name}`,
);
}
const val = attr.value ?? "";
if (
name === "href" ||
name === "src" ||
name === "action" ||
name === "formaction" ||
name === "poster" ||
name === "data"
) {
findings.push(...inspectUrlAttr(`${tag}[${name}]`, val));
}
if (name === "style" && /\burl\s*\(\s*["']?\s*javascript:/i.test(val)) {
addFinding(findings, "css_javascript_url", `${tag} style contains javascript: URL.`);
}
}
for (const child of Array.from(el.children)) {
walkElement(child, findings);
}
}
function nestedAnalyze(fragment: string, findings: ThreatFinding[], depth: number): void {
if (depth <= 0) return;
let doc: Document;
try {
doc = new DOMParser().parseFromString(fragment, "text/html");
} catch {
return;
}
walkElement(doc.documentElement, findings);
}
/**
* High-confidence HTML threat signals for user-generated / API HTML (messages, notices).
*
* Note: This runs after load for iframes in many cases; pairing with iframe `sandbox`
* (see BetterSEQTA Security plugin) is required for reliable script blocking — see plugin comments.
*/
export function analyzeHtmlThreats(html: string): ThreatAnalysis {
const findings: ThreatFinding[] = [];
if (!html || !html.trim()) {
return { blocked: false, findings: [] };
}
let doc: Document;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
return { blocked: false, findings: [] };
}
walkElement(doc.documentElement, findings);
/** SEQTA home modal path uses DOMPurify with onclick allowed; flag removal under stricter rules. */
const permissive = DOMPurify.sanitize(html, {
ADD_ATTR: ["onclick"],
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
const strict = DOMPurify.sanitize(html, {
ALLOW_DATA_ATTR: false,
ALLOW_UNKNOWN_PROTOCOLS: false,
});
if (strict !== permissive) {
addFinding(
findings,
"dompurify_delta",
"Content was altered under strict sanitization (likely inline handlers or risky markup).",
);
}
return {
blocked: findings.length > 0,
findings,
};
}
+234
View File
@@ -0,0 +1,234 @@
import type { ThreatAnalysis } from "./analyzeHtmlThreats";
import {
buildIncidentReport,
copyIncidentReport,
downloadIncidentReportPdf,
formatIncidentReportPlainText,
openIncidentReportEmail,
type IncidentReport,
} from "./incidentReport";
/** Mounted by BetterSEQTA Security on messages / notices when HTML is blocked. */
export const SECURITY_BLOCK_HOST_CLASS = "bss-security-block-host";
/** Body-fixed layer for message pane (survives React replacing iframe wrappers). */
export const SECURITY_MESSAGE_OVERLAY_CLASS = "bss-security-message-overlay";
const HOST_CLASS = SECURITY_BLOCK_HOST_CLASS;
const REPORT_LAYER_CLASS = "bss-security-report-layer";
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
style: Partial<CSSStyleDeclaration>,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
Object.assign(node.style, style);
if (text !== undefined) node.textContent = text;
return node;
}
function button(
label: string,
onClick: () => void,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
Object.assign(btn.style, {
padding: "10px 18px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(220,38,38,0.35)",
color: "#fff",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
transition: "background 0.2s, transform 0.2s",
} as CSSStyleDeclaration);
btn.addEventListener("mouseenter", () => {
btn.style.background = "rgba(220,38,38,0.55)";
});
btn.addEventListener("mouseleave", () => {
btn.style.background = "rgba(220,38,38,0.35)";
});
btn.addEventListener("click", onClick);
return btn;
}
function removeExistingReportLayer(doc: Document): void {
doc.querySelectorAll(`.${REPORT_LAYER_CLASS}`).forEach((n) => n.remove());
}
export interface MountBlockedContentOptions {
surface: "message" | "notice";
analysis: ThreatAnalysis;
rawSnippet?: string;
contextTitle?: string;
contextSubtitle?: string;
hostDocument?: Document;
/**
* Panel lives on document.body (fixed layer). Omits `position: relative` so the caller can pin position/size.
*/
rootOverlay?: boolean;
}
export function mountBlockedContentUi(
container: HTMLElement,
options: MountBlockedContentOptions,
): () => void {
const doc = options.hostDocument ?? document;
container.innerHTML = "";
container.classList.add(HOST_CLASS);
const basePanel: Partial<CSSStyleDeclaration> = {
boxSizing: "border-box",
minHeight: options.rootOverlay ? "min(100%, 260px)" : "220px",
padding: "24px",
borderRadius: "12px",
background:
"linear-gradient(145deg, rgba(30,30,35,0.98), rgba(20,20,24,0.98))",
border: "1px solid rgba(239,68,68,0.45)",
color: "#f4f4f5",
fontFamily: "system-ui, Segoe UI, Roboto, sans-serif",
lineHeight: "1.5",
};
if (options.rootOverlay) {
Object.assign(container.style, {
...basePanel,
height: "100%",
overflow: "auto",
});
} else {
Object.assign(container.style, {
...basePanel,
position: "relative",
});
}
const title = el("h2", {
margin: "0 0 12px 0",
fontSize: "20px",
fontWeight: "700",
color: "#fff",
}, "BetterSEQTA Security");
const lead = el("p", {
margin: "0 0 12px 0",
fontSize: "15px",
color: "#e4e4e7",
});
lead.textContent =
"This content was not shown because BetterSEQTA+ detected potentially malicious HTML (for example scripts or dangerous links). This helps protect your account from cross-site scripting.";
const admin = el("p", {
margin: "0 0 20px 0",
fontSize: "14px",
color: "#a1a1aa",
});
admin.textContent =
"Contact your school SEQTA or IT administrator so they can remove or fix the message or notice at source.";
const actions = el("div", {
display: "flex",
flexWrap: "wrap",
gap: "12px",
alignItems: "center",
});
let latestReport: IncidentReport | null = null;
const openReport = async () => {
latestReport = await buildIncidentReport({
surface: options.surface,
analysis: options.analysis,
rawSnippet: options.rawSnippet,
contextTitle: options.contextTitle,
contextSubtitle: options.contextSubtitle,
});
removeExistingReportLayer(doc);
const layer = doc.createElement("div");
layer.className = REPORT_LAYER_CLASS;
Object.assign(layer.style, {
position: "fixed",
inset: "0",
zIndex: "2147483647",
background: "rgba(0,0,0,0.55)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
boxSizing: "border-box",
});
const panel = doc.createElement("div");
Object.assign(panel.style, {
maxWidth: "640px",
width: "100%",
maxHeight: "85vh",
overflow: "auto",
background: "#18181b",
color: "#fafafa",
borderRadius: "12px",
border: "1px solid #3f3f46",
padding: "24px",
boxShadow: "0 25px 50px rgba(0,0,0,0.45)",
});
const pre = doc.createElement("pre");
pre.style.whiteSpace = "pre-wrap";
pre.style.wordBreak = "break-word";
pre.style.fontSize = "12px";
pre.style.lineHeight = "1.45";
pre.style.margin = "0 0 16px 0";
pre.textContent = formatIncidentReportPlainText(latestReport);
const row = doc.createElement("div");
row.style.display = "flex";
row.style.flexWrap = "wrap";
row.style.gap = "10px";
const closeLayer = () => layer.remove();
row.appendChild(
button("Close", closeLayer),
);
row.appendChild(
button("Copy report", async () => {
if (!latestReport) return;
await copyIncidentReport(latestReport);
}),
);
row.appendChild(
button("Download PDF", () => {
if (!latestReport) return;
downloadIncidentReportPdf(latestReport);
}),
);
row.appendChild(
button("Email", () => {
if (!latestReport) return;
openIncidentReportEmail(latestReport);
}),
);
panel.appendChild(pre);
panel.appendChild(row);
layer.appendChild(panel);
layer.addEventListener("click", (e) => {
if (e.target === layer) closeLayer();
});
doc.body.appendChild(layer);
};
actions.appendChild(button("View report", () => void openReport()));
container.appendChild(title);
container.appendChild(lead);
container.appendChild(admin);
container.appendChild(actions);
return () => {
container.innerHTML = "";
container.classList.remove(HOST_CLASS);
removeExistingReportLayer(doc);
};
}
+156
View File
@@ -0,0 +1,156 @@
import { jsPDF } from "jspdf";
import browser from "webextension-polyfill";
import type { ThreatAnalysis, ThreatFinding } from "./analyzeHtmlThreats";
export type ThreatSurface = "message" | "notice";
export interface IncidentReport {
generatedAtIso: string;
surface: ThreatSurface;
extensionVersion: string;
pageUrl: string;
contextTitle?: string;
contextSubtitle?: string;
analysis: ThreatAnalysis;
contentFingerprint?: string;
}
async function sha256Hex(text: string): Promise<string> {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function getExtensionVersion(): string {
try {
return browser.runtime?.getManifest?.()?.version ?? "unknown";
} catch {
return "unknown";
}
}
export async function buildIncidentReport(opts: {
surface: ThreatSurface;
analysis: ThreatAnalysis;
rawSnippet?: string;
contextTitle?: string;
contextSubtitle?: string;
extensionVersion?: string;
}): Promise<IncidentReport> {
let fingerprint: string | undefined;
if (opts.rawSnippet?.trim()) {
try {
if (typeof crypto !== "undefined" && crypto.subtle) {
fingerprint = await sha256Hex(opts.rawSnippet.slice(0, 50_000));
}
} catch {
fingerprint = undefined;
}
}
const version = opts.extensionVersion ?? getExtensionVersion();
return {
generatedAtIso: new Date().toISOString(),
surface: opts.surface,
extensionVersion: version,
pageUrl:
typeof window !== "undefined" ? window.location.href : "",
contextTitle: opts.contextTitle,
contextSubtitle: opts.contextSubtitle,
analysis: opts.analysis,
contentFingerprint: fingerprint,
};
}
function formatFindings(findings: ThreatFinding[]): string {
return findings.map((f, i) => `${i + 1}. [${f.kind}] ${f.detail}`).join("\n");
}
export function formatIncidentReportPlainText(report: IncidentReport): string {
const lines = [
"BetterSEQTA+ Security — incident report",
"=====================================",
"",
`Generated (UTC): ${report.generatedAtIso}`,
`Surface: ${report.surface}`,
`Extension version: ${report.extensionVersion}`,
`Page URL: ${report.pageUrl}`,
];
if (report.contextTitle) lines.push(`Title / subject: ${report.contextTitle}`);
if (report.contextSubtitle) lines.push(`Detail: ${report.contextSubtitle}`);
if (report.contentFingerprint) {
lines.push(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
}
lines.push("", "Findings:", formatFindings(report.analysis.findings), "");
lines.push(
"Next steps:",
"- Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source.",
"- Attach this report (PDF or pasted text) when reporting.",
);
return lines.join("\n");
}
export async function copyIncidentReport(report: IncidentReport): Promise<void> {
const text = formatIncidentReportPlainText(report);
await navigator.clipboard.writeText(text);
}
export function downloadIncidentReportPdf(report: IncidentReport): void {
const doc = new jsPDF({ unit: "pt", format: "a4" });
const margin = 48;
let y = margin;
const lineHeight = 14;
const pageHeight = doc.internal.pageSize.getHeight();
const maxWidth = doc.internal.pageSize.getWidth() - margin * 2;
const pushLines = (text: string, bold = false) => {
doc.setFont("helvetica", bold ? "bold" : "normal");
const wrapped = doc.splitTextToSize(text, maxWidth) as string[];
for (const line of wrapped) {
if (y > pageHeight - margin) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += lineHeight;
}
};
pushLines("BetterSEQTA+ Security — incident report", true);
pushLines(`Generated (UTC): ${report.generatedAtIso}`);
pushLines(`Surface: ${report.surface}`);
pushLines(`Extension version: ${report.extensionVersion}`);
pushLines(`Page URL: ${report.pageUrl}`);
if (report.contextTitle) pushLines(`Title / subject: ${report.contextTitle}`);
if (report.contextSubtitle) pushLines(`Detail: ${report.contextSubtitle}`);
if (report.contentFingerprint) {
pushLines(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
}
pushLines("");
pushLines("Findings:", true);
for (const f of report.analysis.findings) {
pushLines(`• [${f.kind}] ${f.detail}`);
}
pushLines("");
pushLines("Next steps:", true);
pushLines(
"Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source. Attach this PDF when reporting.",
);
doc.save(`betterseqta-security-report-${report.surface}-${Date.now()}.pdf`);
}
export function openIncidentReportEmail(report: IncidentReport): void {
const subject = encodeURIComponent(
"SEQTA: suspected malicious HTML blocked by BetterSEQTA+ Security",
);
const body = encodeURIComponent(
formatIncidentReportPlainText(report).slice(0, 1800) +
"\n\n[If truncated: use Copy in the report dialog for the full text.]",
);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}
+31 -2
View File
@@ -17,6 +17,8 @@ import {
toISODate,
weekRangeContaining,
} from "@/seqta/utils/Loaders/engageParentTimetable";
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
export function updateEngageHomeMenuActive(isHome: boolean): void {
const home = document.getElementById("homebutton");
@@ -356,6 +358,12 @@ function openEngageNoticeModal(
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
const threatAnalysis = analyzeHtmlThreats(cleanContent);
const noticeBlocked = threatAnalysis.blocked;
const noticeBodyInner = noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`;
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
@@ -389,7 +397,7 @@ function openEngageNoticeModal(
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
${noticeBodyInner}
</div>
</div>
</div>
@@ -406,6 +414,23 @@ function openEngageNoticeModal(
document.body.appendChild(modal);
if (noticeBlocked) {
const mountEl = modal.querySelector(
".bss-security-notice-mount",
) as HTMLElement | null;
if (mountEl) {
mountBlockedContentUi(mountEl, {
surface: "notice",
analysis: threatAnalysis,
rawSnippet: cleanContent.slice(0, 50_000),
contextTitle: String(notice.title ?? ""),
contextSubtitle: [notice.staff, notice.label_title]
.filter(Boolean)
.join(" · "),
});
}
}
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
@@ -431,7 +456,11 @@ function openEngageNoticeModal(
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
${
noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`
}
</div>
`;
document.body.appendChild(tempMeasureDiv);
+32 -12
View File
@@ -13,6 +13,8 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
let LessonInterval: any;
let currentSelectedDate = new Date();
@@ -113,16 +115,7 @@ export async function loadHomePage() {
callHomeTimetable(TodayFormatted, true);
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeYear = activeClass?.year;
const allSubjectsInYear = classes
.filter((c: any) => c.year === activeYear)
.flatMap((c: any) => c.subjects || []);
const seen = new Set<string>();
const activeSubjects = allSubjectsInYear.filter((s: any) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
const activeSubjects = activeClass?.subjects || [];
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
const currentAssessments = assessments
.filter((a: any) => activeSubjectCodes.includes(a.code))
@@ -393,6 +386,12 @@ function openNoticeModal(
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
const threatAnalysis = analyzeHtmlThreats(cleanContent);
const noticeBlocked = threatAnalysis.blocked;
const noticeBodyInner = noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`;
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
@@ -426,7 +425,7 @@ function openNoticeModal(
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
${noticeBodyInner}
</div>
</div>
</div>
@@ -443,6 +442,23 @@ function openNoticeModal(
document.body.appendChild(modal);
if (noticeBlocked) {
const mountEl = modal.querySelector(
".bss-security-notice-mount",
) as HTMLElement | null;
if (mountEl) {
mountBlockedContentUi(mountEl, {
surface: "notice",
analysis: threatAnalysis,
rawSnippet: cleanContent.slice(0, 50_000),
contextTitle: String(notice.title ?? ""),
contextSubtitle: [notice.staff, notice.label_title]
.filter(Boolean)
.join(" · "),
});
}
}
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
@@ -468,7 +484,11 @@ function openNoticeModal(
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
${
noticeBlocked
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
: `<div class="notice-content-body">${cleanContent}</div>`
}
</div>
`;
document.body.appendChild(tempMeasureDiv);
@@ -34,6 +34,10 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.5 - Assessment weighting override & fixes</h1>
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
<li>Fixed the display of weightings that could not automatically be discovered.</li>
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
<li>Added advanced colour adjustments variables for theme customisation.</li>
+15
View File
@@ -0,0 +1,15 @@
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});