From 77d029653ecabf4c620505d223afbcfde3c1f769 Mon Sep 17 00:00:00 2001 From: moneybagsmahoney <79066541+WheeledCord@users.noreply.github.com> Date: Tue, 25 Feb 2025 19:48:21 +1300 Subject: [PATCH 1/2] Update script.js --- script.js | 401 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 289 insertions(+), 112 deletions(-) diff --git a/script.js b/script.js index 089b9d5..8616266 100644 --- a/script.js +++ b/script.js @@ -7,6 +7,7 @@ window.addEventListener("load", () => { // #endregion // #region Scrollbar +// Helper function to convert a hex color to RGB function hexToRgb(hex) { return { r: parseInt(hex.substring(1, 3), 16), @@ -15,10 +16,12 @@ function hexToRgb(hex) { }; } +// Helper function to convert RGB values to a hex color function rgbToHex({ r, g, b }) { return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } +// Main function to interpolate between two colors function interpolate(color1, color2, percent) { const rgb1 = hexToRgb(color1); const rgb2 = hexToRgb(color2); @@ -26,181 +29,355 @@ function interpolate(color1, color2, percent) { const interpolateValue = (start, end) => Math.round(start + (end - start) * percent); - return rgbToHex({ + const interpolatedColor = { r: interpolateValue(rgb1.r, rgb2.r), g: interpolateValue(rgb1.g, rgb2.g), b: interpolateValue(rgb1.b, rgb2.b), - }); + }; + + return rgbToHex(interpolatedColor); } const debounce = (fn) => { let frame; + return (...params) => { - if (frame) cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => fn(...params)); - }; + if (frame) { + cancelAnimationFrame(frame); + } + + frame = requestAnimationFrame(() => { + fn(...params); + }); + + } }; -const searchResults = document.querySelector(".search-results"); +const searchResults = document.querySelector('.search-results') const storeScroll = () => { - let scrollamount = searchResults.scrollTop / searchResults.scrollHeight; - searchResults.style.setProperty( - "--scroll-amount", - interpolate("#4287f5", "#460c85", scrollamount) - ); -}; + let scrollamount = (searchResults.scrollTop / searchResults.scrollHeight); + searchResults.style.setProperty("--scroll-amount", interpolate("#4287f5","#460c85", scrollamount)); +} -searchResults.addEventListener("scroll", debounce(storeScroll), { passive: true }); +searchResults.addEventListener('scroll', debounce(storeScroll), { passive: true }); storeScroll(); // #endregion -// #region Fetching Data Securely -let idx, fullData, subjectList; +// #region Prepare necessary variables for searching +// Searching +let idx; -fetch("subjects.json") - .then((res) => res.json()) - .then((data) => { - subjectList = data; - }); +let fullData; -fetch("searchIndex.json") - .then((res) => res.json()) - .then((data) => { - idx = lunr(function () { - this.ref("id"); - this.field("title"); - this.field("subject"); - this.field("number"); - this.field("credits"); - data.forEach((doc) => this.add(doc), this); - }); - fullData = data; - }); +let subjectList; + +fetch("subjects.json").then((res) => {return res.json()}).then((data)=>{ + subjectList = data; +}); + +fetch("searchIndex.json").then((res) => { return res.json()}).then((data) => { + idx = lunr(function () { + this.ref('id'); + this.field('title'); + this.field('subject'); + this.field('number'); + this.field('credits'); + + data.forEach(function (doc) { + this.add(doc) + }, this) + }) + + fullData = data; +}); // #endregion // #region Searching Logic +// Screens +const contributorsScreen = document.querySelector(".contributors-screen"); +const examsNotFound = document.querySelector(".subject-not-found-block"); +const loadingWheel = document.querySelector(".loading-wheel"); +const githubContributeScreen = document.querySelector( + ".github-contribute-screen" +); + +// Texts in input field const searchText = document.querySelector("#search-text"); const autocomplete = document.querySelector("#autocomplete"); - -function sanitizeText(text) { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; // Escapes any potentially harmful HTML -} +const minCredits = 0 function setAutoCompleteText() { - autocomplete.textContent = searchText.value; // Use textContent instead of innerHTML + autocomplete.innerHTML = searchText.value; + + if (searchText.value.length != 0) { + for (let index = 0; index < subjectList.length; index++) { + const subject = subjectList[index]; - if (searchText.value.length !== 0) { - for (let subject of subjectList) { - if (subject.toLowerCase().startsWith(searchText.value.toLowerCase())) { + if ( + subject.toLowerCase().substr(0, searchText.value.length) == + searchText.value.toLowerCase() + ) { autocomplete.textContent = subject; - searchText.value = subject; + + let autoCompleteContent = subject + .substr(0, searchText.value.length) + .replace("&", "&"); + + searchText.value = autoCompleteContent; break; } } } else { - autocomplete.textContent = "Enter standard number or subject name"; + autocomplete.innerHTML = "Enter standard number or subject name"; } } +function changeScreensDisplay( + examsNotFoundDisplay, + searchResultsDisplay, + loadingWheelDisplay, + contributorsScreenDisplay +) { + examsNotFound.style.display = examsNotFoundDisplay; + searchResults.style.display = searchResultsDisplay; + loadingWheel.style.display = loadingWheelDisplay; + contributorsScreen.style.display = contributorsScreenDisplay; + + // Always disable githubContribtuteScreen + githubContributeScreen.style.display = "none"; +} + +function addFilter(filter) { + // Add space if there's already text in the bar + if (searchText.value != "") { + searchText.value += " "; + } + + searchText.value += filter; + autocomplete.innerHTML = ""; + searchText.focus(); +} + function extractSearchData(search) { + // Credit Selector + // Matchs mincredits: then a number, and the i at the end means it's case insensitive const creditsRegex = /mincredits:(\d+)/i; const creditsMatch = search.match(creditsRegex); const minCredits = creditsMatch ? parseInt(creditsMatch[1], 10) : null; - search = search.replace(creditsRegex, "").trim(); + search = search.replace(creditsRegex, '').trim(); + // Level selector + // Matchs level: then a number or S, and the i at the end means it's case insensitive const levelRegex = /level:(\d+|S)/i; const levelMatch = search.match(levelRegex); const level = levelMatch ? levelMatch[1] : null; - search = search.replace(levelRegex, "").trim(); + search = search.replace(levelRegex, '').trim(); - return { search, minCredits, level }; + // Search query sanitization + // Adds a plus before each word so that lunr matches it properly + const sanitizationRegex = /(? fullData[result.ref]["credits"] >= minCredits); - } - if (level) { - subjectExams = subjectExams.filter( - (result) => fullData[result.ref]["level"] == level || fullData[result.ref]["level"] == "All" - ); - } - - if (subjectExams.length > 0) { - subjectExams.forEach((result) => { - const card = document.createElement("div"); - card.classList.add("search-results-card", "flex-c-c"); - - card.innerHTML = ` -
-
-

${sanitizeText(fullData[result.ref]["number"])}

-

${sanitizeText(fullData[result.ref]["start-year"])} - ${sanitizeText(fullData[result.ref]["end-year"])}

-
-
-

${sanitizeText(fullData[result.ref]["title"])} | Credits: ${sanitizeText(fullData[result.ref]["credits"].toString())}

-
-
-
-
-

${sanitizeText(fullData[result.ref]["level"])}

-

Lvl

-
- - `; - - card.querySelector(".download-plus").addEventListener("click", () => { - window.open( - `https://raw.githubusercontent.com/JelyMe/NCEAPapers/main/zipped/${sanitizeText(fullData[result.ref]["number"])}.zip` - ); - }); - - searchResults.appendChild(card); - }); - } else { - searchResults.innerHTML = "

No results found.

"; - } + changeScreensDisplay("none", "none", "flex", "none"); + + new Promise( + (resolve, reject) => { + setTimeout(() => { + const { search, minCredits, level } = extractSearchData(searchText.value); + + let subjectExams = idx.search(search); + // Filtering + if (minCredits) { + subjectExams = subjectExams.filter((result) => fullData[result.ref]['credits'] >= minCredits ); + } + if (level) { + subjectExams = subjectExams.filter((result) => fullData[result.ref]['level'] == level || fullData[result.ref]['level'] == "All"); + } + + if (subjectExams.length > 0) { + // Add the exam card buttons for each exam there are for that subject + subjectExams.forEach((result) => { + searchResults.innerHTML += + `
+
+
+

+ `+fullData[result.ref]["number"]+` +

+

+ `+fullData[result.ref]["start-year"] + '-' + fullData[result.ref]["end-year"]+` +

+
+
+

`+fullData[result.ref]["title"]+` | Credits: `+fullData[result.ref]["credits"]+`

+
+
+ +
+ +
+

+ `+fullData[result.ref]["level"]+` +

+

Lvl

+
+ + + +
` + }); + + resolve(); + } + else if (subjectExams.length === 0) { + // If there are no exams for that subject, show the error screen (why face emoji) + changeScreensDisplay("flex", "none", "none", "none"); + } + + }, 15); + /* + We added a 5 millisecond delay because of a behaviour in JavaScript + Seems like "tasks" in JavaScript will be blocking, until a certain task is done JavaScript + will move onto the next task. Thus, adding a 5 millisecond delay to this will allow the loading + wheel to show + */ + } + ).then( + () => { + // Once exams are found show the search results + changeScreensDisplay("none", "flex", "none", "none"); + } + ); } -document.querySelector("#search-text").addEventListener("keydown", (event) => { - if (event.keyCode === 9 && autocomplete.textContent !== "Enter standard number or subject name") { +const tabKeyCode = 9; +const enterKeyCode = 13; + +//Stupid tab button, it has to be done on the keydown event because when keyup, the focus will have been shifted +document.querySelector('#search-text').addEventListener('keydown', (event) => { + + /* + Why we use autocomplete.textContent instead of innerHTML: + This is because Earth & Space Science has an ampersand, which is displayed as & in HTML. + If we use autocomplete.innerHTML, then we are comparing searchText.value (which is plain) text, + with HTML markup. + This leads to errors such as when autocompleting for Earth & Space Science, the searchText will + be displayed as "Earth & Space Science". + To avoid this we have to use the raw plain text of autocomplete. Hence, we use textContent. + */ + + // Keycode 9 is tab key + if (event.keyCode == tabKeyCode && autocomplete.textContent != "Enter standard number or subject name") { + // Prevents pressing the tab key to select elements event.preventDefault(); - searchText.value = autocomplete.textContent; + + if (searchText.value == autocomplete.textContent) { + showSearchResults(); + } + else { + // If the current input text is not equal to autoComplete's text, will auto complete + searchText.value = autocomplete.textContent; + } } }); -document.querySelector("#search-text").addEventListener("keyup", (event) => { - if (event.keyCode === 13) showSearchResults(); +// Enter key autocomplete and stuff. Done on keyup because enter key is not special like tab +document.querySelector('#search-text').addEventListener('keyup', (event) => { + + // Key code 13 is enter key + if (event.keyCode == enterKeyCode && autocomplete.textContent != "Enter standard number or subject name") { + + if (searchText.value == autocomplete.textContent) { + showSearchResults(); + } + else { + // If the current input text is not equal to autoComplete's text, will auto complete + searchText.value = autocomplete.textContent; + } + } + setAutoCompleteText(); }); // #endregion +// #region Contributors Screen +// Contributors button +const contributorsButton = document.querySelector(".contributors-button"); + +contributorsButton.addEventListener("click", (e) => { + if (contributorsScreen.style.display === "flex") { + // Display search results + changeScreensDisplay("none", "flex", "none", "none"); + } else { + /* + Will stop the click event fired by the user clicking + from going up to the body. If this was not included, then + the body click event will be fired and code will be executed + (the code to be executed is below this callback function) + */ + e.stopPropagation(); + + changeScreensDisplay("none", "none", "none", "flex"); + } +}); + +/* +If the user clicks anywhere on the screen, and the contributors screen is showing, +then will hide contributor screen and show the exam paper search results +*/ +document.body.addEventListener("click", () => { + if (contributorsScreen.style.display === "flex") { + // Display search results + changeScreensDisplay("none", "flex", "none", "none"); + } +}); +// #endregion + // #region Subjects Screen let showingSubjects = false; -document.querySelector("#subject-button").addEventListener("click", () => { - searchResults.innerHTML = showingSubjects ? "" : subjectList.map((subject) => - `` - ).join("\n"); +document.querySelector("#subject-button").addEventListener("click", ()=>{ + if (!showingSubjects) { - showingSubjects = !showingSubjects; -}); + // Remove search results + searchResults.innerHTML = ""; -searchResults.addEventListener("click", (event) => { - if (event.target.classList.contains("subject-card")) { - searchText.value = event.target.textContent; - showSearchResults(); + changeScreensDisplay("none", "flex", "none", "none"); + + // Show subject list buttons + for (let index = 0; index < subjectList.length; index++) { + const subject = subjectList[index]; + searchResults.innerHTML += `\n'; + } + showingSubjects = true; } -}); + else { + // Hides the subject list buttons + searchResults.innerHTML = ""; + showingSubjects = false; + } +}) + +// Searching for subject exams from clicking the subject buttons +function search(term){ + + autocomplete.textContent = term; + searchText.value = term; + + showSearchResults(); +} // #endregion From 7e7149f4a56b3dffbed4674c86b15aaa498bcfa6 Mon Sep 17 00:00:00 2001 From: moneybagsmahoney <79066541+WheeledCord@users.noreply.github.com> Date: Tue, 25 Feb 2025 19:49:10 +1300 Subject: [PATCH 2/2] fix xss --- script.js | 86 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/script.js b/script.js index 8616266..e1a5511 100644 --- a/script.js +++ b/script.js @@ -6,6 +6,19 @@ window.addEventListener("load", () => { }); // #endregion +// --- Added sanitization helper function --- +function escapeHTML(str) { + if (typeof str !== "string") { + str = String(str); + } + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + // #region Scrollbar // Helper function to convert a hex color to RGB function hexToRgb(hex) { @@ -73,24 +86,28 @@ let fullData; let subjectList; -fetch("subjects.json").then((res) => {return res.json()}).then((data)=>{ - subjectList = data; -}); +fetch("subjects.json") + .then((res) => res.json()) + .then((data)=>{ + subjectList = data; + }); -fetch("searchIndex.json").then((res) => { return res.json()}).then((data) => { - idx = lunr(function () { - this.ref('id'); - this.field('title'); - this.field('subject'); - this.field('number'); - this.field('credits'); +fetch("searchIndex.json") + .then((res) => res.json()) + .then((data) => { + idx = lunr(function () { + this.ref('id'); + this.field('title'); + this.field('subject'); + this.field('number'); + this.field('credits'); + + data.forEach(function (doc) { + this.add(doc) + }, this) + }) - data.forEach(function (doc) { - this.add(doc) - }, this) - }) - - fullData = data; + fullData = data; }); // #endregion @@ -105,11 +122,13 @@ const githubContributeScreen = document.querySelector( // Texts in input field const searchText = document.querySelector("#search-text"); +// Use textContent when simply displaying text to avoid HTML injection. const autocomplete = document.querySelector("#autocomplete"); const minCredits = 0 function setAutoCompleteText() { - autocomplete.innerHTML = searchText.value; + // Use textContent instead of innerHTML for user-supplied text. + autocomplete.textContent = searchText.value; if (searchText.value.length != 0) { for (let index = 0; index < subjectList.length; index++) { @@ -119,6 +138,7 @@ function setAutoCompleteText() { subject.toLowerCase().substr(0, searchText.value.length) == searchText.value.toLowerCase() ) { + // Set the autocomplete text safely autocomplete.textContent = subject; let autoCompleteContent = subject @@ -130,7 +150,8 @@ function setAutoCompleteText() { } } } else { - autocomplete.innerHTML = "Enter standard number or subject name"; + // Set a safe default text message + autocomplete.textContent = "Enter standard number or subject name"; } } @@ -156,7 +177,7 @@ function addFilter(filter) { } searchText.value += filter; - autocomplete.innerHTML = ""; + autocomplete.textContent = ""; searchText.focus(); } @@ -210,19 +231,29 @@ function showSearchResults() { if (subjectExams.length > 0) { // Add the exam card buttons for each exam there are for that subject subjectExams.forEach((result) => { + // Sanitize each dynamic field + const examNumber = escapeHTML(fullData[result.ref]["number"]); + const startYear = escapeHTML(fullData[result.ref]["start-year"]); + const endYear = escapeHTML(fullData[result.ref]["end-year"]); + const examTitle = escapeHTML(fullData[result.ref]["title"]); + const creditsText = escapeHTML(fullData[result.ref]["credits"]); + const examLevel = escapeHTML(fullData[result.ref]["level"]); + // For URL parts, use encodeURIComponent + const examNumberURL = encodeURIComponent(fullData[result.ref]["number"]); + searchResults.innerHTML += `

- `+fullData[result.ref]["number"]+` + ${examNumber}

- `+fullData[result.ref]["start-year"] + '-' + fullData[result.ref]["end-year"]+` + ${startYear}-${endYear}

-

`+fullData[result.ref]["title"]+` | Credits: `+fullData[result.ref]["credits"]+`

+

${examTitle} | Credits: ${creditsText}

@@ -230,15 +261,15 @@ function showSearchResults() {

- `+fullData[result.ref]["level"]+` + ${examLevel}

Lvl

- -
` + `; }); resolve(); @@ -361,7 +392,9 @@ document.querySelector("#subject-button").addEventListener("click", ()=>{ // Show subject list buttons for (let index = 0; index < subjectList.length; index++) { const subject = subjectList[index]; - searchResults.innerHTML += `\n'; + // Use JSON.stringify to safely pass the subject string into the onclick handler, + // and escapeHTML for the button's inner text. + searchResults.innerHTML += `\n'; } showingSubjects = true; } @@ -374,7 +407,6 @@ document.querySelector("#subject-button").addEventListener("click", ()=>{ // Searching for subject exams from clicking the subject buttons function search(term){ - autocomplete.textContent = term; searchText.value = term;