Ludy c17b4e29ff
cache the release notes in the browser session (#2673)
# Description

Please provide a summary of the changes, including relevant motivation
and context.

Closes
https://github.com/Stirling-Tools/Stirling-PDF/security/code-scanning/164

## Checklist

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have performed a self-review of my own code
- [ ] I have attached images of the change if it is UI based
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] If my code has heavily changed functionality I have updated
relevant docs on [Stirling-PDFs doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
- [x] My changes generate no new warnings
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
2025-01-12 13:46:46 +00:00

512 lines
17 KiB
HTML

<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{releases.title}, header=#{releases.title})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon history">update</span>
<span class="tool-header-text" th:text="#{releases.header}">Release Notes</span>
</div>
<div class="alert alert-info" role="alert">
<strong th:text="#{releases.current.version}">Current Installed Version</strong>:
<span id="currentVersion" th:text="${@appVersion}"></span>
</div>
<div class="alert alert-warning" role="alert">
<span th:text="#{releases.note}">All release notes are only available in English</span>
</div>
<div id="loading" class="text-center my-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="error-message" class="alert alert-danger d-none" role="alert">
Failed to load release notes. Please try again later.
</div>
<!-- Release Notes Container -->
<div id="release-notes-container" class="release-notes-container">
<!-- Release notes will be dynamically inserted here -->
</div>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<style>
.release-notes-container {
margin-top: 2rem;
}
.release-card {
border: 1px solid #dee2e6;
border-radius: 0.25rem;
margin-bottom: 1.5rem;
padding: 1rem;
}
.release-card.current-version {
border-color: #28a745;
background-color: rgba(40, 167, 69, 0.05);
}
.release-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.release-header h3 {
margin: 0;
display: flex;
gap: 1rem;
align-items: center;
}
.version {
font-weight: bold;
}
.release-date {
color: #6c757d;
font-size: 0.9em;
}
.release-body {
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
}
.release-link {
color: #0d6efd;
text-decoration: none;
}
.release-link:hover {
text-decoration: underline;
}
</style>
<script th:inline="javascript">
/*<![CDATA[*/
// Get the current version from the appVersion bean
const appVersion = /*[[${@appVersion}]]*/ '';
// GitHub API configuration
const REPO_OWNER = 'Stirling-Tools';
const REPO_NAME = 'Stirling-PDF';
const GITHUB_API = 'https://api.github.com/repos/' + REPO_OWNER + '/' + REPO_NAME;
const GITHUB_URL = 'https://github.com/' + REPO_OWNER + '/' + REPO_NAME;
const MAX_RELEASES = 8;
// Secure element creation helper
function createElement(tag, attributes = {}, children = []) {
const element = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => {
if (typeof value === 'string') {
element.setAttribute(key, value);
}
});
children.forEach(child => {
if (typeof child === 'string') {
element.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
element.appendChild(child);
}
});
return element;
}
const ALLOWED_TAGS = {
'a': ['href', 'target', 'rel', 'class'],
'img': ['src', 'alt', 'width', 'height', 'style'],
'br': [],
'p': [],
'div': [],
'span': []
};
// Function to safely create HTML elements from string
function createSafeElement(htmlString) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
function sanitizeNode(node) {
// Safety check for null/undefined
if (!node) return null;
// Handle text nodes
if (node.nodeType === Node.TEXT_NODE) {
return node.cloneNode(true);
}
// Handle element nodes
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
// Check if tag is allowed
if (!ALLOWED_TAGS[tagName]) {
return document.createTextNode(node.textContent);
}
// Create new element
const cleanElement = document.createElement(tagName);
// Copy allowed attributes
const allowedAttributes = ALLOWED_TAGS[tagName];
Array.from(node.attributes).forEach(attr => {
if (allowedAttributes.includes(attr.name)) {
let value = attr.value;
if (attr.name === 'href' || attr.name === 'src') {
try {
value = encodeURI(value);
} catch {
return;
}
}
cleanElement.setAttribute(attr.name, value);
}
});
// Add security attributes for links
if (tagName === 'a') {
cleanElement.setAttribute('rel', 'noopener noreferrer');
}
// Process children
Array.from(node.childNodes).forEach(child => {
const cleanChild = sanitizeNode(child);
if (cleanChild) {
cleanElement.appendChild(cleanChild);
}
});
return cleanElement;
}
// If not text or element node, return null
return null;
}
// Get the actual content from the body
const content = doc.body.children;
if (!content || content.length === 0) {
return null;
}
// If it's a single element, process it directly
if (content.length === 1) {
return sanitizeNode(content[0]);
}
// If multiple elements, wrap them in a div
const wrapper = document.createElement('div');
Array.from(content).forEach(node => {
const cleanNode = sanitizeNode(node);
if (cleanNode) {
wrapper.appendChild(cleanNode);
}
});
return wrapper;
} catch (error) {
console.error('Error parsing HTML:', error);
return null;
}
}
function processGitHubReferences(text) {
if (!text || typeof text !== 'string') {
return text;
}
let currentText = text;
let match;
let lastIndex = 0;
const result = document.createElement('span');
const urlRegex = new RegExp('https://github\\.com/' + REPO_OWNER + '/' + REPO_NAME + '/(?:issues|pull)/(\\d+)', 'g');
while ((match = urlRegex.exec(text)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
result.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
}
// Create link element
const link = document.createElement('a');
link.href = encodeURI(match[0]);
link.textContent = `#${match[1]}`; // Use issue/PR number
link.className = 'release-link';
link.target = '_blank';
link.rel = 'noopener noreferrer';
result.appendChild(link);
lastIndex = match.index + match[0].length;
}
// Add remaining text after last match
if (lastIndex < text.length) {
result.appendChild(document.createTextNode(text.substring(lastIndex)));
}
return result;
}
// Update formatText function to handle processGitHubReferences properly
function formatText(text) {
const container = document.createElement('div');
if (!text || typeof text !== 'string') {
console.error('Invalid input to formatText:', text);
return container;
}
let textWithoutComments;
try {
textWithoutComments = text.replace(
/<!-- Release notes generated using configuration in .github\/release\.yml at main -->/,
''
);
} catch (error) {
console.error('Error in replace operation:', error);
return container;
}
// Split the text into lines
let lines;
try {
lines = textWithoutComments.split('\n');
} catch (error) {
console.error('Error in split operation:', error);
return container;
}
let currentList = null;
lines.forEach(line => {
const trimmedLine = line.trim();
// Skip empty lines but add spacing
if (!trimmedLine) {
if (currentList) {
container.appendChild(currentList);
currentList = null;
}
container.appendChild(document.createElement('br'));
return;
}
// Check if the line is HTML
if (trimmedLine.startsWith('<') && trimmedLine.endsWith('>')) {
if (currentList) {
container.appendChild(currentList);
currentList = null;
}
const safeElement = createSafeElement(trimmedLine);
if (safeElement) {
container.appendChild(safeElement);
} else {
// If HTML parsing fails, treat as plain text
container.appendChild(document.createTextNode(trimmedLine));
}
return;
}
// Check for headers
const headerMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/);
if (headerMatch) {
if (currentList) {
container.appendChild(currentList);
currentList = null;
}
const headerLevel = headerMatch[1].length;
const headerContent = headerMatch[2];
// Process GitHub references in headers
const processedContent = processGitHubReferences(headerContent);
const header = createElement(`h${headerLevel}`);
header.appendChild(processedContent);
container.appendChild(header);
return;
}
// Check for bullet points
const bulletMatch = trimmedLine.match(/^[-*]\s+(.+)$/);
if (bulletMatch) {
if (!currentList) {
currentList = document.createElement('ul');
}
const listContent = bulletMatch[1];
const listItem = document.createElement('li');
// Process GitHub references in list items
listItem.appendChild(processGitHubReferences(listContent));
currentList.appendChild(listItem);
return;
}
// If we reach here and have a list, append it
if (currentList) {
container.appendChild(currentList);
currentList = null;
}
// Handle regular paragraph
const paragraph = document.createElement('p');
paragraph.appendChild(processGitHubReferences(trimmedLine));
container.appendChild(paragraph);
});
// Append any remaining list
if (currentList) {
container.appendChild(currentList);
}
return container;
}
const MAX_PREVIOUS_RELEASES = 5;
function compareVersions(v1, v2) {
const normalize = v => v.replace(/^v/, '');
const v1Parts = normalize(v1).split('.').map(Number);
const v2Parts = normalize(v2).split('.').map(Number);
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const v1Part = v1Parts[i] || 0;
const v2Part = v2Parts[i] || 0;
if (v1Part > v2Part) return 1;
if (v1Part < v2Part) return -1;
}
return 0;
}
async function loadReleases() {
const container = document.getElementById('release-notes-container');
const loading = document.getElementById('loading');
const errorMessage = document.getElementById('error-message');
try {
loading.classList.remove('d-none');
errorMessage.classList.add('d-none');
// Clear container safely
while (container.firstChild) {
container.removeChild(container.firstChild);
}
const cachedReleases = sessionStorage.getItem('releases');
let releases;
if (cachedReleases) {
releases = JSON.parse(cachedReleases);
console.log("Read from storage");
} else {
const response = await fetch(GITHUB_API + '/releases');
if (!response.ok) {
if (response.status === 403) {
throw new Error('API rate limit exceeded');
}
if (response.status === 404) {
throw new Error('Repository not found');
}
throw new Error('Failed to fetch releases');
}
releases = await response.json();
sessionStorage.setItem('releases', JSON.stringify(releases));
}
// Sort releases by version number (descending)
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
// Find index of current version
const currentVersionIndex = releases.findIndex(release =>
compareVersions(release.tag_name, 'v' + appVersion) === 0 ||
compareVersions(release.tag_name, appVersion) === 0
);
if (currentVersionIndex === -1) {
container.appendChild(createElement('div', {
class: 'alert alert-warning'
}, ['Current version not found in releases.']));
return;
}
// Get current version and 8 previous releases
const endIndex = Math.min(currentVersionIndex + MAX_PREVIOUS_RELEASES + 1, releases.length);
const relevantReleases = releases.slice(currentVersionIndex, endIndex);
if (relevantReleases.length === 0) {
container.appendChild(createElement('div', {
class: 'alert alert-warning'
}, ['No releases found.']));
return;
}
relevantReleases.forEach((release, index) => {
const isCurrentVersion = index === 0; // First release in the array is current version
const releaseCard = createElement('div', {
class: `release-card ${isCurrentVersion ? 'current-version' : ''}`
});
const header = createElement('div', { class: 'release-header' });
const h3 = createElement('h3', {}, [
createElement('span', { class: 'version' }, [release.tag_name]),
createElement('span', { class: 'release-date' }, [
new Date(release.created_at).toLocaleDateString()
])
]);
header.appendChild(h3);
if (isCurrentVersion) {
header.appendChild(createElement('span', {
class: 'badge bg-success'
}, ['Installed']));
}
releaseCard.appendChild(header);
const body = createElement('div', { class: 'release-body' });
body.appendChild(formatText(release.body || 'No release notes available.'));
releaseCard.appendChild(body);
container.appendChild(releaseCard);
});
} catch (error) {
console.error('Error loading releases:', error);
errorMessage.classList.remove('d-none');
} finally {
loading.classList.add('d-none');
}
}
// Load releases when the page loads
document.addEventListener('DOMContentLoaded', loadReleases);
/*]]>*/
</script>
</body>
</html>