5 Commits

Author SHA1 Message Date
be04b24eb0 Release version 2.1.3 2026-09-11 23:30:48 +02:00
3c899d971b fix(fullscreen): stop the recalc loop once the header stops animating
recalcWhileCollapsing ran a requestAnimationFrame loop and cancelled it on
the header's max-height transitionend. When no max-height transition ran,
for example because the header was already in its target state, that event
never fired and the loop recalculated the scroll container on every frame
for the rest of the page's life; each further call started another such
loop. The loop now continues only while header.getAnimations() reports a
running animation.

The resize handler entered or exited fullscreen on every resize event. It
now returns early when the UI fullscreen state already matches the body
class, so a resize that changes nothing starts no recalc loop.

A Cypress spec spies on adjustScrollContainerHeight after exitFullscreen()
and requires the call count to stop growing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:25:51 +02:00
a7a6d205e1 fix(iframe): stop observing when the iframe's first location is cross-origin
observeIframeNavigation read iframe.contentWindow.location.href once,
outside any guard. For a cross-origin iframe that read throws a
SecurityError, which escaped as an uncaught exception and never reached
the polling loop, whose own read of the same property is already guarded.
The first read is now guarded as well, and the observer returns because it
cannot follow a cross-origin frame anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:25:51 +02:00
2c12c1c327 fix(card): make the icon fallback onerror handler compile
The handler assigned through an optional chain,
this.nextElementSibling?.style.display='inline-block'. An optional chain
is not a valid assignment target, so the whole attribute failed to compile
("SyntaxError: Invalid left-hand side in assignment") whenever an icon
image failed to load: the broken image stayed visible and the fallback
<i> icon never appeared. An explicit null check does the same and
compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:25:51 +02:00
2400cc2ea1 fix(csp): ship the interface strings as a JSON data block, not an inline script
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>
2026-09-11 23:25:51 +02:00
9 changed files with 67 additions and 20 deletions

View File

@@ -1,5 +1,14 @@
# Changelog
## [2.1.3] - 2026-09-11
* CSP: UI strings ship as a JSON data block, not a CSP-blocked inline script
* Cards: broken icon images fall back to the icon font (*onerror* fixed)
* Iframe: a cross-origin iframe no longer throws a *SecurityError* on load
* Fullscreen: scroll recalculation stops when the header animation ends
* Fullscreen: a resize that keeps the fullscreen state triggers no recalc
* Test coverage: no executable inline scripts; recalc must stop (Cypress)
## [2.1.2] - 2026-09-10
* CI: lint tests install the project first, so the image publishes again

View File

@@ -71,6 +71,18 @@ describe('Fullscreen Toggle', () => {
});
});
it('stops recalculating once the header has nothing left to animate', () => {
cy.window().then(win => {
cy.spy(win, 'adjustScrollContainerHeight').as('recalc');
win.exitFullscreen();
});
cy.wait(500);
cy.get('@recalc').then(spy => {
const settled = spy.callCount;
cy.wait(500).then(() => expect(spy.callCount).to.eq(settled));
});
});
it('toggleFullscreen() toggles into and out of fullscreen', () => {
// Toggle into fullscreen
cy.window().invoke('toggleFullscreen');

View File

@@ -9,31 +9,16 @@ function updateUrlFullscreen(enabled) {
window.history.replaceState({}, '', url);
}
/**
* Starts a requestAnimationFrame loop that calls your recalc methods,
* and stops automatically when the headers max-height transition ends.
*/
function recalcWhileCollapsing() {
const header = document.querySelector('header');
if (!header) return;
// 1) Start the RAF loop
let rafId;
const step = () => {
adjustScrollContainerHeight();
updateCustomScrollbar();
rafId = requestAnimationFrame(step);
if (header.getAnimations().length > 0) requestAnimationFrame(step);
};
step();
// 2) Listen for the end of the max-height transition
function onEnd(e) {
if (e.propertyName === 'max-height') {
cancelAnimationFrame(rafId);
header.removeEventListener('transitionend', onEnd);
}
}
header.addEventListener('transitionend', onEnd);
}
function enterFullscreen() {
@@ -100,6 +85,7 @@ document.addEventListener('fullscreenchange', function() {
});
window.addEventListener('resize', function() {
var isUiFs = Math.abs(window.innerHeight - screen.height) < 2;
if (isUiFs === document.body.classList.contains('fullscreen')) return;
if (isUiFs) enterFullscreen();
else exitFullscreen();
});

View File

@@ -168,7 +168,12 @@ function observeIframeNavigation() {
const iframe = mainElement.querySelector("iframe");
if (!iframe || !iframe.contentWindow) return;
let lastUrl = iframe.contentWindow.location.href;
let lastUrl;
try {
lastUrl = iframe.contentWindow.location.href;
} catch (e) {
return;
}
setInterval(() => {
try {

View File

@@ -1,3 +1,6 @@
const i18nBlock = document.getElementById('i18n');
window.I18N = i18nBlock ? JSON.parse(i18nBlock.textContent) : {};
function t(source) {
return (window.I18N || {})[source] || source;
}

View File

@@ -71,7 +71,7 @@
</div>
<!-- Include modal -->
{% include "moduls/modal.html.j2" %}
<script>window.I18N = {{ ui_strings | tojson }};</script>
<script id="i18n" type="application/json">{{ ui_strings | tojson }}</script>
{% for name in [
'modal',
'navigation',

View File

@@ -9,7 +9,7 @@
src="{{ asset_src(card.icon) }}"
alt="{{ card.title }}"
style="width:100px; height:auto;"
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
onerror="this.style.display='none'; if (this.nextElementSibling) this.nextElementSibling.style.display='inline-block';">
{% if card.icon.class %}
<i class="{{ card.icon.class }}" style="display:none;"></i>
{% endif %}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portfolio-ui"
version = "2.1.2"
version = "2.1.3"
description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -1,5 +1,6 @@
import json
import os
import re
import shutil
import subprocess
import sys
@@ -111,6 +112,37 @@ class TestEscaping(AppRouteMixin, unittest.TestCase):
self.assertIn("&lt;script&gt;alert(&#39;config&#39;)", body)
class TestContentSecurityPolicy(AppRouteMixin, unittest.TestCase):
def test_page_ships_no_executable_inline_script(self):
body = self.client.get("/de/").get_data(as_text=True)
inline = [
tag
for tag in re.findall(r"<script\b[^>]*>", body)
if "src=" not in tag and 'type="application/json"' not in tag
]
self.assertEqual(
inline,
[],
"a host CSP can only hash an inline script whose content it knows, "
"and this one changes with every language",
)
def test_interface_strings_ship_as_a_json_data_block(self):
i18n._catalogs["de"] = {"Open": "</script><script>alert(1)</script>"}
body = self.client.get("/de/").get_data(as_text=True)
block = re.search(
r'<script id="i18n" type="application/json">(.*?)</script>', body, re.S
)
self.assertIsNotNone(block)
self.assertEqual(
json.loads(block.group(1))["Open"], "</script><script>alert(1)</script>"
)
class TestApodBackground(AppRouteMixin, unittest.TestCase):
def setUp(self):
super().setUp()