mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-11 21:46:47 +00:00
base.html.j2 set window.I18N from an executable inline <script>. A host CSP can only allow an inline script by hash or by 'unsafe-inline', and this script's content changes with every language, so no hash can cover it. Infinito.Nexus serves the dashboard with a hash-based script-src-elem whenever its logout feature is off, and there the script was blocked: every page logged "Executing inline script violates the following Content Security Policy directive 'script-src-elem ...'" and window.I18N stayed undefined. The strings now ship as <script id="i18n" type="application/json">, which the browser does not execute and CSP does not govern; modal.js parses the block before its first use. tojson escapes <, > and &, so a string that contains "</script>" cannot end the block early. Integration tests require that a page ships no executable inline script and that a catalogue string containing "</script>" survives the round trip through the block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
5.3 KiB
JavaScript
161 lines
5.3 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 isSafeUrl(url) {
|
|
try {
|
|
const parsed = new URL(String(url == null ? '' : url), window.location.href);
|
|
return SAFE_URL_SCHEMES.includes(parsed.protocol);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 = '';
|
|
}
|