mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-23 19:03:18 +00:00
An empty value resolved against window.location.href, so a page without an iframe parameter passed both the scheme check and the origin allowlist carrying its own URL. Every load entered fullscreen and framed the page inside itself, and the navigation observer wrote that URL back into the parameter, nesting it deeper on every poll until the URL ran to kilometres. safeUrl now rejects a missing value, the caller tests the parameter before validating it, and the history entry keeps the URL it was given rather than the resolved one. The regression spec covers the four properties a page without the parameter must have, and fails on each of them when the defect is put back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
168 lines
5.4 KiB
JavaScript
168 lines
5.4 KiB
JavaScript
const i18nBlock = document.getElementById('i18n');
|
|
window.I18N = i18nBlock ? JSON.parse(i18nBlock.textContent) : {};
|
|
|
|
function t(source) {
|
|
return (window.I18N || {})[source] || source;
|
|
}
|
|
|
|
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
|
|
|
|
function safeUrl(url) {
|
|
if (url == null || String(url) === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = new URL(String(url), window.location.href);
|
|
return SAFE_URL_SCHEMES.includes(parsed.protocol) ? parsed.href : null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isSafeUrl(url) {
|
|
return safeUrl(url) !== null;
|
|
}
|
|
|
|
function iconAndName(item) {
|
|
const nodes = [];
|
|
if (item.icon && item.icon.class) {
|
|
const icon = document.createElement('i');
|
|
icon.className = item.icon.class;
|
|
nodes.push(icon, document.createTextNode(' '));
|
|
}
|
|
nodes.push(document.createTextNode(item.name == null ? '' : item.name));
|
|
return nodes;
|
|
}
|
|
|
|
function renderMarkdown(content) {
|
|
const escaped = String(content).replace(/</g, '<').replace(/>/g, '>');
|
|
const parsed = new DOMParser().parseFromString(marked.parse(escaped), 'text/html');
|
|
|
|
parsed.querySelectorAll('a[href]').forEach((anchor) => {
|
|
if (!SAFE_URL_SCHEMES.includes(anchor.protocol)) {
|
|
anchor.replaceWith(...anchor.childNodes);
|
|
}
|
|
});
|
|
parsed.querySelectorAll('img[src]').forEach((image) => {
|
|
if (!SAFE_URL_SCHEMES.includes(image.protocol)) {
|
|
image.replaceWith(image.alt || '');
|
|
}
|
|
});
|
|
|
|
return parsed.body.innerHTML;
|
|
}
|
|
|
|
function openDynamicPopup(subitem) {
|
|
closeAllModals();
|
|
const modalTitle = document.getElementById('dynamicModalLabel');
|
|
modalTitle.replaceChildren(...iconAndName(subitem));
|
|
|
|
const identifierBox = document.getElementById('dynamicIdentifierBox');
|
|
const modalContent = document.getElementById('dynamicModalContent');
|
|
if (subitem.identifier) {
|
|
identifierBox.classList.remove('d-none');
|
|
modalContent.value = subitem.identifier;
|
|
} else {
|
|
identifierBox.classList.add('d-none');
|
|
modalContent.value = '';
|
|
}
|
|
|
|
function toggleBox(boxId, textId, content) {
|
|
const box = document.getElementById(boxId);
|
|
if (content) {
|
|
box.classList.remove('d-none');
|
|
document.getElementById(textId).innerHTML = renderMarkdown(content);
|
|
} else {
|
|
box.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
toggleBox('dynamicModalWarning', 'dynamicModalWarningText', subitem.warning);
|
|
toggleBox('dynamicModalInfo', 'dynamicModalInfoText', subitem.info);
|
|
|
|
const descriptionText = document.getElementById('dynamicDescriptionText');
|
|
if (!subitem.url && subitem.description) {
|
|
descriptionText.classList.remove('d-none');
|
|
descriptionText.innerText = subitem.description;
|
|
} else {
|
|
descriptionText.classList.add('d-none');
|
|
descriptionText.innerText = '';
|
|
}
|
|
|
|
const linkBox = document.getElementById('dynamicModalLink');
|
|
const linkHref = document.getElementById('dynamicModalLinkHref');
|
|
if (subitem.url) {
|
|
linkBox.classList.remove('d-none');
|
|
linkHref.href = subitem.url;
|
|
if (!isSafeUrl(subitem.url)) {
|
|
linkHref.removeAttribute('href');
|
|
}
|
|
linkHref.innerText = subitem.description || t("Open Link");
|
|
linkHref.classList.remove('iframe');
|
|
linkHref.onclick = null;
|
|
if (subitem.iframe) {
|
|
linkHref.classList.add('iframe');
|
|
linkHref.onclick = function(event) {
|
|
event.preventDefault();
|
|
openIframe(subitem.url);
|
|
closeAllModals();
|
|
};
|
|
}
|
|
} else {
|
|
linkBox.classList.add('d-none');
|
|
linkHref.href = '#';
|
|
}
|
|
function populateSection(sectionId, listId, items, onClickHandler) {
|
|
const section = document.getElementById(sectionId);
|
|
const list = document.getElementById(listId);
|
|
list.innerHTML = '';
|
|
|
|
if (items && items.length > 0) {
|
|
section.classList.remove('d-none');
|
|
items.forEach(item => {
|
|
const listItem = document.createElement('li');
|
|
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
|
|
const label = document.createElement('span');
|
|
label.replaceChildren(...iconAndName(item));
|
|
const button = document.createElement('button');
|
|
button.className = 'btn btn-outline-secondary btn-sm';
|
|
button.textContent = t('Open');
|
|
listItem.replaceChildren(label, button);
|
|
button.addEventListener('click', () => onClickHandler(item));
|
|
list.appendChild(listItem);
|
|
});
|
|
} else {
|
|
section.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
populateSection('dynamicAlternativesSection', 'dynamicAlternativesList', subitem.alternatives, openDynamicPopup);
|
|
populateSection('dynamicChildrenSection', 'dynamicChildrenList', subitem.children, openDynamicPopup);
|
|
|
|
const copyButton = document.getElementById('dynamicCopyButton');
|
|
copyButton.onclick = () => {
|
|
modalContent.select();
|
|
navigator.clipboard.writeText(modalContent.value).then(() => {
|
|
alert(t('Identifier copied to clipboard!'));
|
|
});
|
|
};
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('dynamicModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function closeAllModals() {
|
|
const modals = document.querySelectorAll('.modal.show');
|
|
modals.forEach(modal => {
|
|
const modalInstance = bootstrap.Modal.getInstance(modal);
|
|
if (modalInstance) {
|
|
modalInstance.hide();
|
|
}
|
|
});
|
|
const backdrops = document.querySelectorAll('.modal-backdrop');
|
|
backdrops.forEach(backdrop => backdrop.remove());
|
|
document.body.classList.remove('modal-open');
|
|
document.body.style.overflow = '';
|
|
document.body.style.paddingRight = '';
|
|
}
|