46 Commits

Author SHA1 Message Date
d386160237 Release version 2.2.2 2026-09-23 10:27:21 +02:00
7de3c74f3c fix(iframe): stop an absent iframe parameter from framing the page in itself
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>
2026-09-23 10:15:52 +02:00
7353e8d96a fix(iframe): hand the sinks the validated URL, not the parameter
The scheme and origin checks lived in a boolean guard in another function, so the raw query parameter still reached the iframe src, the history entry and window.open. The validator now returns the normalised href or null, and every sink consumes only that. Which URLs are accepted does not change: openIframe still checks the scheme alone, because the modal opens configured targets that are not among the page's iframe links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:10:05 +02:00
4cc8052a88 test: parse the page instead of matching tags with a regex
The inline-script check matched <script\b[^>]*> and then filtered the matched text, so a > inside an attribute value split one tag into a fragment that had already lost the attribute the filter looks for. An html.parser subclass decides on the parsed attributes instead. The i18n test imported unittest twice, once plain and once as a from-import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:09:23 +02:00
87c4405aa4 ci: run the Node jobs on the version Cypress supports
Cypress 16 declares node ^22 || ^24 || >=26, and both jobs installed it on Node 20, which npm reported as EBADENGINE on every run. Node 25 would match the Dockerfile but falls in the gap between ^24 and >=26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:08:24 +02:00
ff6e7d3689 fix(vendor): find the marked 18 browser build
marked 18 ships lib/marked.umd.js and no minified bundle at all, so the postinstall hook threw 'no browser UMD build found' and took npm install down with it. Both the JavaScript lint job and the end-to-end job died there. Teach the candidate list that layout; the older, minified layouts keep their precedence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:07:20 +02:00
4eb57011ba Release version 2.2.1 2026-09-23 01:00:06 +02:00
70d5871e94 ci: cancel the runs of a branch that is closed
The concurrency group only deduplicates live work on a branch; a run that was queued or still building kept its runner after the branch was deleted or its pull request closed. A delete and a pull_request closed trigger now cancel everything of that branch that has not completed, minus the cancelling run itself, which shares the head branch on a pull request event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 00:59:08 +02:00
10ea289563 ci: run one CI run per branch at a time
Every push started its own run, so two pushes to the same branch raced through the same jobs and burned runner minutes twice. One concurrency group per ref now covers all six jobs, including the reusable workflows. Pull request runs cancel their predecessor; branch pushes queue instead, so a release push is never cancelled between the per-architecture push and the manifest merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 00:54:03 +02:00
62a2aa5827 Merge pull request #14 from kevinveenbirkenbach/dependabot/github_actions/docker/setup-buildx-action-4.4.1
build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.1
2026-09-23 00:50:07 +02:00
b0c8316b0d Merge pull request #7 from kevinveenbirkenbach/dependabot/npm_and_yarn/app/jquery-4.0.0
build(deps): bump jquery from 3.6.0 to 4.0.0 in /app
2026-09-23 00:49:51 +02:00
dependabot[bot]
d5104f5680 build(deps): bump jquery from 3.6.0 to 4.0.0 in /app
Bumps [jquery](https://github.com/jquery/jquery) from 3.6.0 to 4.0.0.
- [Release notes](https://github.com/jquery/jquery/releases)
- [Changelog](https://github.com/jquery/jquery/blob/main/changelog.md)
- [Commits](https://github.com/jquery/jquery/compare/3.6.0...4.0.0)

---
updated-dependencies:
- dependency-name: jquery
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:49:42 +00:00
fc646d8826 Merge pull request #6 from kevinveenbirkenbach/dependabot/docker/python-3.14-slim
build(deps): bump python from 3.12-slim to 3.14-slim
2026-09-23 00:48:49 +02:00
1bff38ed64 Merge pull request #5 from kevinveenbirkenbach/dependabot/github_actions/cypress-io/github-action-7.4.4
build(deps): bump cypress-io/github-action from 6.10.9 to 7.4.4
2026-09-23 00:48:37 +02:00
dd0454274b Merge pull request #4 from kevinveenbirkenbach/dependabot/docker/node-25-slim
build(deps): bump node from 22-slim to 25-slim
2026-09-23 00:47:49 +02:00
fd4d1e77c8 Merge pull request #3 from kevinveenbirkenbach/dependabot/github_actions/hadolint/hadolint-action-3.5.0
build(deps): bump hadolint/hadolint-action from 3.3.0 to 3.5.0
2026-09-23 00:47:33 +02:00
d1043c4cb0 Merge pull request #2 from kevinveenbirkenbach/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 6 to 7
2026-09-23 00:47:08 +02:00
dependabot[bot]
62a6a8cb66 build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.1
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.4.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](8d2750c68a...f87e5991a6)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:46:28 +00:00
4d1092fe5e Merge pull request #8 from kevinveenbirkenbach/dependabot/github_actions/docker/build-push-action-7.4.0
build(deps): bump docker/build-push-action from 6.19.2 to 7.4.0
2026-09-23 00:45:06 +02:00
2ed4164dc3 Merge pull request #13 from kevinveenbirkenbach/dependabot/npm_and_yarn/app/marked-18.0.13
build(deps): bump marked from 4.3.0 to 18.0.13 in /app
2026-09-23 00:44:31 +02:00
85e1f437e0 Merge pull request #9 from kevinveenbirkenbach/dependabot/npm_and_yarn/app/bootstrap-5.3.8
build(deps): bump bootstrap from 5.2.2 to 5.3.8 in /app
2026-09-23 00:43:47 +02:00
dependabot[bot]
0733af88ba build(deps): bump marked from 4.3.0 to 18.0.13 in /app
Bumps [marked](https://github.com/markedjs/marked) from 4.3.0 to 18.0.13.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v4.3.0...v18.0.13)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 18.0.13
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:43:38 +00:00
31bed38478 Merge branch 'main' into dependabot/npm_and_yarn/app/bootstrap-5.3.8 2026-09-23 00:43:29 +02:00
3f75ebd327 Merge pull request #10 from kevinveenbirkenbach/dependabot/npm_and_yarn/app/cypress-16.1.0
build(deps-dev): bump cypress from 14.5.4 to 16.1.0 in /app
2026-09-23 00:42:20 +02:00
169891fcf0 Merge pull request #12 from kevinveenbirkenbach/dependabot/npm_and_yarn/app/fortawesome/fontawesome-free-7.3.1
build(deps): bump @fortawesome/fontawesome-free from 6.7.2 to 7.3.1 in /app
2026-09-23 00:42:05 +02:00
dependabot[bot]
578d84de0c build(deps): bump @fortawesome/fontawesome-free in /app
Bumps [@fortawesome/fontawesome-free](https://github.com/FortAwesome/Font-Awesome) from 6.7.2 to 7.3.1.
- [Release notes](https://github.com/FortAwesome/Font-Awesome/releases)
- [Changelog](https://github.com/FortAwesome/Font-Awesome/blob/7.x/CHANGELOG.md)
- [Commits](https://github.com/FortAwesome/Font-Awesome/compare/6.7.2...7.3.1)

---
updated-dependencies:
- dependency-name: "@fortawesome/fontawesome-free"
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:41:39 +00:00
20b36e518d Merge pull request #11 from kevinveenbirkenbach/dependabot/npm_and_yarn/app/bootstrap-icons-1.13.1
build(deps): bump bootstrap-icons from 1.9.1 to 1.13.1 in /app
2026-09-23 00:41:34 +02:00
d3a37f2268 Merge pull request #1 from kevinveenbirkenbach/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-09-23 00:40:56 +02:00
dependabot[bot]
fe6c2a0f11 build(deps): bump bootstrap-icons from 1.9.1 to 1.13.1 in /app
Bumps [bootstrap-icons](https://github.com/twbs/icons) from 1.9.1 to 1.13.1.
- [Release notes](https://github.com/twbs/icons/releases)
- [Commits](https://github.com/twbs/icons/compare/v1.9.1...v1.13.1)

---
updated-dependencies:
- dependency-name: bootstrap-icons
  dependency-version: 1.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:27 +00:00
dependabot[bot]
21f24a3c0d build(deps-dev): bump cypress from 14.5.4 to 16.1.0 in /app
Bumps [cypress](https://github.com/cypress-io/cypress) from 14.5.4 to 16.1.0.
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/cypress-io/cypress/compare/v14.5.4...v16.1.0)

---
updated-dependencies:
- dependency-name: cypress
  dependency-version: 16.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:20 +00:00
dependabot[bot]
20566ca0c4 build(deps): bump bootstrap from 5.2.2 to 5.3.8 in /app
Bumps [bootstrap](https://github.com/twbs/bootstrap) from 5.2.2 to 5.3.8.
- [Release notes](https://github.com/twbs/bootstrap/releases)
- [Commits](https://github.com/twbs/bootstrap/compare/v5.2.2...v5.3.8)

---
updated-dependencies:
- dependency-name: bootstrap
  dependency-version: 5.3.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:19 +00:00
dependabot[bot]
f998ceb298 build(deps): bump docker/build-push-action from 6.19.2 to 7.4.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.4.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](10e90e3645...c3c9e263c2)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:13 +00:00
dependabot[bot]
abc17e8b1e build(deps): bump python from 3.12-slim to 3.14-slim
Bumps python from 3.12-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:09 +00:00
dependabot[bot]
c188a6089d build(deps): bump cypress-io/github-action from 6.10.9 to 7.4.4
Bumps [cypress-io/github-action](https://github.com/cypress-io/github-action) from 6.10.9 to 7.4.4.
- [Release notes](https://github.com/cypress-io/github-action/releases)
- [Changelog](https://github.com/cypress-io/github-action/blob/master/CHANGELOG.md)
- [Commits](f790eee7a5...01e3b659a4)

---
updated-dependencies:
- dependency-name: cypress-io/github-action
  dependency-version: 7.4.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:06 +00:00
dependabot[bot]
b76bbba4a3 build(deps): bump node from 22-slim to 25-slim
Bumps node from 22-slim to 25-slim.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 25-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:03 +00:00
dependabot[bot]
362ca6f499 build(deps): bump hadolint/hadolint-action from 3.3.0 to 3.5.0
Bumps [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) from 3.3.0 to 3.5.0.
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](2332a7b74a...06be81baf8)

---
updated-dependencies:
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:39:00 +00:00
dependabot[bot]
13e0331460 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:38:57 +00:00
dependabot[bot]
5d39720d54 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 22:38:51 +00:00
49a0d2fe6d ci: let Dependabot watch the dependencies
The SHA-pinned actions, the two base images, the compose file, the Python project and the npm assets had no update stream, so a pinned reference only moved when someone noticed it by hand. Adds a daily Dependabot config for all five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 00:38:10 +02:00
dae34991e9 Release version 2.2.0 2026-09-23 00:30:21 +02:00
b7874eab1d ci: publish the image for amd64 and arm64 natively
The publish job built a single-arch image on ubuntu-latest, so every release manifest carried linux/amd64 only. Split it into a version job that exports the semver tag and image name, a matrix publish job that builds each architecture on its own native runner (ubuntu-latest, ubuntu-24.04-arm) and pushes an arch-suffixed tag, and a manifest job that joins both under the release tag. Native runners avoid the QEMU emulation layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 00:28:35 +02:00
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
21 changed files with 340 additions and 81 deletions

47
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "00:00"
labels:
- "dependencies"
- "ci"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
time: "00:00"
labels:
- "dependencies"
- "docker"
- package-ecosystem: "docker-compose"
directory: "/"
schedule:
interval: "daily"
time: "00:00"
labels:
- "dependencies"
- "docker"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
time: "00:00"
labels:
- "dependencies"
- "python"
- package-ecosystem: "npm"
directory: "/app"
schedule:
interval: "daily"
time: "00:00"
labels:
- "dependencies"
- "javascript"

30
.github/workflows/cancel.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Cancel runs
on:
delete:
pull_request:
types:
- closed
permissions:
actions: write
jobs:
cancel:
name: Cancel the runs of a closed branch
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.event.ref_type == 'branch'
steps:
- name: Cancel every queued or running workflow of the branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.event.ref }}
run: |
set -euo pipefail
gh run list --branch "${BRANCH}" --limit 100 \
--json databaseId,status \
--jq '.[] | select(.status != "completed") | .databaseId' \
| sed "/^${GITHUB_RUN_ID}$/d" \
| xargs -r -n1 gh run cancel

View File

@@ -8,6 +8,10 @@ on:
tags-ignore:
- "**"
concurrency:
group: ci-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -31,8 +35,8 @@ jobs:
contents: read
security-events: write
publish:
name: Publish image
version:
name: Detect release version
runs-on: ubuntu-latest
needs:
- security
@@ -41,11 +45,14 @@ jobs:
if: github.event_name == 'push'
permissions:
contents: read
packages: write
outputs:
found: ${{ steps.semver.outputs.found }}
version: ${{ steps.semver.outputs.version }}
image: ${{ steps.image.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -64,16 +71,34 @@ jobs:
fi
- name: Compute image name
if: steps.semver.outputs.found == 'true'
id: image
run: echo "name=ghcr.io/$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
publish:
name: Publish image (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs:
- version
if: needs.version.outputs.found == 'true'
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Docker Buildx
if: steps.semver.outputs.found == 'true'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
if: steps.semver.outputs.found == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
@@ -81,10 +106,42 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish image
if: steps.semver.outputs.found == 'true'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: ./Dockerfile
platforms: linux/${{ matrix.arch }}
push: true
tags: ${{ steps.image.outputs.name }}:${{ steps.semver.outputs.version }}
tags: ${{ needs.version.outputs.image }}:${{ needs.version.outputs.version }}-${{ matrix.arch }}
manifest:
name: Join architectures
runs-on: ubuntu-latest
needs:
- version
- publish
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the multi-arch manifest
env:
IMAGE: ${{ needs.version.outputs.image }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
@@ -25,10 +25,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -46,12 +46,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: app/package.json
@@ -69,7 +69,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run shellcheck
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
@@ -80,10 +80,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -107,12 +107,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run hadolint
id: hadolint
continue-on-error: true
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
with:
dockerfile: ./Dockerfile
format: sarif

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4

View File

@@ -14,10 +14,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -35,10 +35,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -56,10 +56,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -77,10 +77,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -104,10 +104,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -135,10 +135,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -153,7 +153,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: app/package.json
@@ -190,7 +190,7 @@ jobs:
xvfb
- name: Run Cypress tests
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
uses: cypress-io/github-action@01e3b659a495b41649cdd0aa82e1d1624e26520b # v7.4.4
with:
working-directory: app
install: false

View File

@@ -1,5 +1,38 @@
# Changelog
## [2.2.2] - 2026-09-23
* Vendor assets: marked 18 is found again, so the image build stops failing
* Iframe: a page without an iframe parameter no longer frames itself
* Security: the iframe and the new tab only open a URL the validator returned
* Tests: the inline-script check parses the page instead of matching a regex
* Tests: a regression spec guards the page that carries no iframe parameter
* CI: the Node jobs run the version Cypress 16 supports
## [2.2.1] - 2026-09-23
* Images: releases ship arm64 next to amd64, for ARM servers and Apple Silicon
* CI: each architecture builds on its own native runner, no QEMU emulation
* CI: one run per branch, so two pushes stop racing through the same jobs
* CI: closing a pull request or deleting a branch cancels its pending runs
* Dependencies: Dependabot watches actions, images, compose, pip and npm daily
* Runtime: Python 3.14 and Node 25 base images
* Frontend: Bootstrap 5.3.8, jQuery 4, Font Awesome 7, marked 18, Cypress 16
## [2.2.0] - 2026-09-23
* Images: releases ship arm64 next to amd64, for ARM servers and Apple Silicon
* CI: each architecture builds on its own native runner, no QEMU emulation
## [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

@@ -1,11 +1,11 @@
FROM node:22-slim AS assets
FROM node:25-slim AS assets
WORKDIR /app
COPY app/package.json ./
COPY app/scripts ./scripts
RUN npm install --omit=dev --no-audit --no-fund
FROM python:3.12-slim AS base
FROM python:3.14-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \

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

@@ -0,0 +1,31 @@
describe('A page without an iframe parameter', () => {
beforeEach(() => {
cy.visit('/');
});
it('stays out of fullscreen', () => {
cy.get('body').should('not.have.class', 'fullscreen');
});
it('frames nothing', () => {
cy.get('#main').find('iframe').should('not.exist');
cy.url().should('not.include', 'iframe=');
});
it('never grows an iframe parameter out of its own URL', () => {
cy.wait(2000);
cy.url().then((url) => {
expect((url.match(/iframe/g) || []).length, 'iframe parameters').to.equal(0);
});
});
it('treats an absent URL as unsafe', () => {
cy.window().then((win) => {
expect(win.safeUrl(null), 'null').to.equal(null);
expect(win.safeUrl(''), 'empty string').to.equal(null);
expect(win.safeUrl('javascript:alert(1)'), 'script URL').to.equal(null);
expect(win.safeUrl('/de/'), 'relative path').to.equal(`${win.location.origin}/de/`);
});
});
});

View File

@@ -21,6 +21,7 @@ const SHARED = {
openDynamicPopup: 'readonly',
closeAllModals: 'readonly',
isSafeUrl: 'readonly',
safeUrl: 'readonly',
openIframe: 'readonly',
enterFullscreen: 'readonly',
exitFullscreen: 'readonly',

View File

@@ -1,14 +1,14 @@
{
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"bootstrap": "5.2.2",
"bootstrap-icons": "1.9.1",
"jquery": "3.6.0",
"marked": "^4.3.0"
"bootstrap": "5.3.8",
"@fortawesome/fontawesome-free": "^7.3.1",
"bootstrap-icons": "1.13.1",
"jquery": "4.0.0",
"marked": "^18.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"cypress": "^14.5.1",
"cypress": "^16.1.0",
"eslint": "^10.9.0",
"globals": "^17.11.0"
},

View File

@@ -63,6 +63,7 @@ const markedCandidates = [
path.join(NM, 'marked', 'marked.min.js'), // v4.x
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
path.join(NM, 'marked', 'lib', 'marked.umd.js'), // v16+
];
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');

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

@@ -2,19 +2,20 @@
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
let currentIframeUrl = null;
function isAllowedIframeUrl(url) {
if (!isSafeUrl(url)) {
return false;
function allowedIframeUrl(url) {
const candidate = safeUrl(url);
if (candidate === null) {
return null;
}
const allowedOrigins = new Set([window.location.origin]);
document.querySelectorAll('a.iframe-link[href]').forEach((link) => allowedOrigins.add(link.origin));
return allowedOrigins.has(new URL(url, window.location.href).origin);
return allowedOrigins.has(new URL(candidate).origin) ? candidate : null;
}
// === Auto-open iframe if URL parameter is present ===
window.addEventListener('DOMContentLoaded', () => {
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
if (paramUrl && isAllowedIframeUrl(paramUrl)) {
if (paramUrl && allowedIframeUrl(paramUrl)) {
currentIframeUrl = paramUrl;
enterFullscreen();
openIframe(paramUrl);
@@ -43,7 +44,8 @@ function syncIframeHeight() {
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
function openIframe(url) {
if (!isSafeUrl(url)) {
const target = safeUrl(url);
if (target === null) {
return;
}
@@ -73,7 +75,7 @@ function openIframe(url) {
// Quelle setzen und mit 1500 ms einblenden
$iframe
.attr('src', url)
.attr('src', target)
.fadeIn(1500, function() {
syncIframeHeight();
observeIframeNavigation();
@@ -148,8 +150,8 @@ document.addEventListener("DOMContentLoaded", function() {
*/
function openIframeInNewTab() {
const params = new URLSearchParams(window.location.search);
const iframeUrl = params.get('iframe');
if (iframeUrl && isAllowedIframeUrl(iframeUrl)) {
const iframeUrl = allowedIframeUrl(params.get('iframe'));
if (iframeUrl) {
window.open(iframeUrl, '_blank');
} else {
alert('No iframe is currently open.');
@@ -168,7 +170,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,16 +1,26 @@
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 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) {

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.2.2"
description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -1,10 +1,12 @@
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import Mock, patch
@@ -111,6 +113,48 @@ class TestEscaping(AppRouteMixin, unittest.TestCase):
self.assertIn("&lt;script&gt;alert(&#39;config&#39;)", body)
class InlineScriptCollector(HTMLParser):
def __init__(self):
super().__init__()
self.inline = []
def handle_starttag(self, tag, attrs):
if tag != "script":
return
attributes = dict(attrs)
if "src" in attributes or attributes.get("type") == "application/json":
return
self.inline.append(self.get_starttag_text())
class TestContentSecurityPolicy(AppRouteMixin, unittest.TestCase):
def test_page_ships_no_executable_inline_script(self):
body = self.client.get("/de/").get_data(as_text=True)
collector = InlineScriptCollector()
collector.feed(body)
self.assertEqual(
collector.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()

View File

@@ -2,8 +2,8 @@ import re
import shutil
import tempfile
import unittest
import unittest.mock
from pathlib import Path
from unittest import mock
import yaml
@@ -170,7 +170,7 @@ class TestCatalogMerge(unittest.TestCase):
)
def test_an_unsupported_code_never_becomes_a_path(self):
with mock.patch.object(i18n, "read_catalog") as read:
with unittest.mock.patch.object(i18n, "read_catalog") as read:
self.assertEqual(i18n.catalog("../content/de"), {})
read.assert_not_called()