Finished Iframe Implementation

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-08 01:34:18 +02:00
parent a100c9e63d
commit 9159a0c7d3
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
27 changed files with 460 additions and 55 deletions

View File

@ -0,0 +1,30 @@
from ansible.errors import AnsibleFilterError
import copy
def append_csp_hash(applications, application_id, code_one_liner):
"""
Ensures that applications[application_id].csp.hashes['script-src-elem']
exists and appends the given one-liner (if not already present).
"""
if not isinstance(applications, dict):
raise AnsibleFilterError("`applications` must be a dict")
if application_id not in applications:
raise AnsibleFilterError(f"Unknown application_id: {application_id}")
apps = copy.deepcopy(applications)
app = apps[application_id]
csp = app.setdefault('csp', {})
hashes = csp.setdefault('hashes', {})
existing = hashes.get('script-src-elem', [])
if code_one_liner not in existing:
existing.append(code_one_liner)
hashes['script-src-elem'] = existing
return apps
class FilterModule(object):
def filters(self):
return {
'append_csp_hash': append_csp_hash
}

View File

@ -0,0 +1,44 @@
# filter_plugins/text_filters.py
from ansible.errors import AnsibleFilterError
import re
def to_one_liner(s):
"""
Collapse any multi-line string into a single line,
trim extra whitespace, and remove JavaScript comments.
Supports removal of both '//' line comments and '/*...*/' block comments,
but preserves '//' inside string literals and templating expressions.
"""
if not isinstance(s, str):
raise AnsibleFilterError("to_one_liner() expects a string")
# 1) Remove block comments /* ... */
no_block_comments = re.sub(r'/\*.*?\*/', '', s, flags=re.DOTALL)
# 2) Extract string literals to protect them from comment removal
string_pattern = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"")
literals = []
def _extract(match):
idx = len(literals)
literals.append(match.group(0))
return f"__STR{idx}__"
temp = string_pattern.sub(_extract, no_block_comments)
# 3) Remove line comments // ...
temp = re.sub(r'//.*$', '', temp, flags=re.MULTILINE)
# 4) Restore string literals
for idx, lit in enumerate(literals):
temp = temp.replace(f"__STR{idx}__", lit)
# 5) Collapse all whitespace
one_liner = re.sub(r'\s+', ' ', temp).strip()
return one_liner
class FilterModule(object):
def filters(self):
return {
'to_one_liner': to_one_liner,
}

View File

@ -6,7 +6,7 @@ setup_admin_email: "{{ users.administrator.email }}"
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: true
central_database: true
credentials:
domains:

View File

@ -5,7 +5,7 @@ credentials:
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: true
central_database: true
docker:
services:

View File

@ -6,8 +6,8 @@ accounts:
icon:
class: fa-solid fa-users
children:
- name: Publishing Channels
description: Platforms where I share content.
- name: Follow Us
description: Follow us to stay up to recieve the newest CyMaIS updates
icon:
class: fas fa-newspaper
{% if ["mastodon", "bluesky"] | any_in(group_names) %}
@ -32,7 +32,7 @@ accounts:
icon:
class: fa-brands fa-bluesky
alternatives:
- link: accounts.publishingchannels.microblogs.mastodon
- link: accounts.followus.microblogs.mastodon
identifier: "{{service_provider.contact.bluesky}}"
{% endif %}
{% endif %}
@ -102,7 +102,6 @@ company:
navigation:
header:
children:
- link: accounts.publishingchannels
- name: Contact
description: Get in touch with {{ 'us' if service_provider.type == 'legal' else 'me' }}
icon:
@ -146,4 +145,10 @@ navigation:
class: fa-solid fa-expand-arrows-alt
onclick: "toggleFullscreen()"
- name: Open in new tab
description: Open the currently embedded iframe URL in a fresh browser tab
icon:
class: fa-solid fa-up-right-from-square
onclick: openIframeInNewTab()
{% include 'footer_menu.yaml.j2' %}

View File

@ -0,0 +1,30 @@
window.addEventListener("message", function(event) {
const allowedSuffix = ".{{ primary_domain }}";
const origin = event.origin;
// 1. Only allow messages from *.{{ primary_domain }}
if (!origin.endsWith(allowedSuffix)) return;
const data = event.data;
// 2. Only process valid iframeLocationChange messages
if (data && data.type === "iframeLocationChange" && typeof data.href === "string") {
try {
const hrefUrl = new URL(data.href);
// 3. Only allow redirects to *.{{ primary_domain }}
if (!hrefUrl.hostname.endsWith(allowedSuffix)) return;
// 4. Update the ?iframe= parameter in the browser URL
const newUrl = new URL(window.location);
newUrl.searchParams.set("iframe", hrefUrl.href);
window.history.replaceState({}, "", newUrl);
} catch (e) {
// Invalid or malformed URL ignore
}
}
});
{% if enable_debug | bool %}
console.log("[iframe-sync] Listener for iframe messages is active.");
{% endif %}

View File

@ -3,7 +3,7 @@ features:
css: true
portfolio_iframe: false
simpleicons: true # Activate Brand Icons for your groups
nasa_api_key: false # Set api key to use the Nasa Picture of the Day as Background
javascript: true # Necessary for URL sync
csp:
whitelist:
script-src-elem:

View File

@ -7,3 +7,13 @@
include_role:
name: nginx-modifier-matomo
when: applications | is_feature_enabled('matomo',application_id)
- name: "Activate Portfolio iFrame Notifier for {{ domain }}"
include_role:
name: nginx-modifier-iframe
when: applications | is_feature_enabled('portfolio_iframe', application_id)
- name: "Activate Javascript for {{ domain }}"
include_role:
name: nginx-modifier-javascript
when: applications | is_feature_enabled('javascript', application_id)

View File

@ -2,20 +2,32 @@
sub_filter_once off;
sub_filter_types text/html;
{% set features_css_final = applications.get(application_id).get('features').get('css') | bool %}
{% set features_matomo_final = applications.get(application_id).get('features').get('matomo') | bool %}
{% set modifier_css_enabled = applications | is_feature_enabled('css',application_id) %}
{% set modifier_matomo_enabled = applications | is_feature_enabled('matomo',application_id) %}
{% set modifier_iframe_enabled = applications | is_feature_enabled('portfolio_iframe',application_id) %}
{% set modifier_javascript_enabled = applications | is_feature_enabled('javascript',application_id) %}
{% if features_matomo_final | bool %}
{# Include Global Matomo Tracking #}
{% include 'roles/nginx-modifier-matomo/templates/matomo-tracking.conf.j2' %}
{% if modifier_iframe_enabled or modifier_css_enabled or modifier_matomo_enabled or modifier_javascript_enabled %}
sub_filter '</head>' '
{%- if modifier_css_enabled -%}
{%- include "roles/nginx-modifier-css/templates/head_sub.j2" -%}
{%- endif -%}
{%- if modifier_matomo_enabled -%}
{%- include "roles/nginx-modifier-matomo/templates/head_sub.j2" -%}
{%- endif -%}
{%- if modifier_iframe_enabled -%}
{%- include "roles/nginx-modifier-iframe/templates/head_sub.j2" -%}
{%- endif -%}
{%- if modifier_javascript_enabled -%}
{%- include "roles/nginx-modifier-javascript/templates/head_sub.j2" -%}
{%- endif -%}
</head>';
{% endif %}
{% if features_css_final | bool or features_matomo_final | bool %}
sub_filter '</head>' '{% if features_matomo_final | bool %}{% include 'roles/nginx-modifier-matomo/templates/script.j2' %}{% endif %}{% if features_css_final | bool %}{% include 'roles/nginx-modifier-css/templates/link.j2' %}{% endif %}</head>';
{% if modifier_css_enabled | bool %}
{% include 'roles/nginx-modifier-css/templates/location.conf.j2' %}
{% endif %}
{% if features_css_final | bool %}
{# Include Global CSS Location #}
{% include 'roles/nginx-modifier-css/templates/location.conf.j2' %}
{% if modifier_matomo_enabled %}
{% include 'roles/nginx-modifier-matomo/templates/matomo-tracking.conf.j2' %}
{% endif %}

View File

@ -0,0 +1,24 @@
# 🌐 iFrame Notifier for Nginx
This Ansible role injects a small JavaScript snippet into your HTML responses that enables parent pages to get notified whenever the iframes location changes and forces external links to open in a new tab.
---
## Features
- **Location Change Notification**
Uses `postMessage` to inform the parent window of any URL changes inside the iframe (including pushState/popState events) for seamless SPA support.
- **External Link Handling**
Automatically sets `target="_blank"` and `rel="noopener"` on links pointing outside your primary domain to improve security and user experience.
- **Easy CSP Integration**
Calculates a CSP hash for the injected script so you can safely allow it via your Content Security Policy.
---
## Author
Developed by **Kevin Veen-Birkenbach**
[https://www.veen.world](https://www.veen.world) 🎉

View File

@ -0,0 +1,28 @@
---
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Injects a JS snippet into HTML to notify parent windows of iframe location changes and force external links to new tabs."
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
license: "CyMaIS NonCommercial License (CNCL)"
repository: https://s.veen.world/cymais
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
license_url: "https://s.veen.world/cncl"
min_ansible_version: "2.9"
platforms:
- name: Archlinux
versions:
- rolling
galaxy_tags:
- nginx
- iframe
- javascript
- csp
- security
- postMessage
dependencies:
- nginx

View File

@ -0,0 +1,12 @@
- name: Load iFrame handler JS template
set_fact:
iframe_code: "{{ lookup('template','iframe-handler.js.j2') }}"
- name: Collapse iFrame code into one-liner
set_fact:
iframe_code_one_liner: "{{ iframe_code | to_one_liner }}"
- name: Append iFrame CSP hash
set_fact:
applications: "{{ applications | append_csp_hash(application_id, iframe_code_one_liner) }}"
changed_when: false

View File

@ -0,0 +1 @@
<script>{{ iframe_code_one_liner | replace("'", "\\'") }}</script>

View File

@ -0,0 +1,46 @@
(function() {
var primary = "{{ primary_domain }}";
var allowedOrigin = "https://{{ domains | get_domain('portfolio') }}";
function notifyParent() {
try {
window.parent.postMessage({
type: "iframeLocationChange",
href: window.location.href
}, allowedOrigin);
} catch (e) {}
}
function forceExternalLinks() {
Array.prototype.forEach.call(document.querySelectorAll("a[href]"), function(a) {
try {
var url = new URL(a.href, location);
if (!url.hostname.endsWith(primary)) {
a.target = "_blank";
a.rel = "noopener";
}
} catch (e) {}
});
}
window.addEventListener("load", function() {
notifyParent();
forceExternalLinks();
});
window.addEventListener("popstate", function() {
notifyParent();
forceExternalLinks();
});
// SPA support
var _pushState = history.pushState;
history.pushState = function() {
_pushState.apply(history, arguments);
notifyParent();
forceExternalLinks();
};
})();
{% if enable_debug | bool %}
console.log("[iframe-sync] Sender for iframe messages is active.");
{% endif %}

View File

@ -0,0 +1,28 @@
# 🌐 Global JavaScript Injector for Nginx
## Description
This Ansible role injects a custom JavaScript snippet into all HTML responses served by Nginx. It leverages Nginxs `sub_filter` to seamlessly insert your application-specific script just before the closing `</head>` tag, ensuring that your code runs on every page load—perfect for global feature flags, analytics, or UI enhancements.
## Features
- **One-line Script Injection**
Collapses your JavaScript into a single line and injects it via `sub_filter` for minimal footprint and maximal compatibility.
- **Easy CSP Integration**
Automatically computes and appends a CSP hash entry for your script, so you can lock down Content Security Policy without lifting a finger.
- **Conditional Activation**
Activates only when you enable the `javascript` feature for a given application, keeping your server blocks clean and performant.
- **Debug Mode**
Supports an `enable_debug` flag that appends optional `console.log` statements for easier troubleshooting in staging or development.
## Author
Developed by **Kevin Veen-Birkenbach**
Consulting & Coaching Solutions — [veen.world](https://www.veen.world)
---
Happy automating! 🎉

View File

@ -0,0 +1,28 @@
---
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Injects a custom JavaScript snippet into Nginx-served HTML responses via sub_filter."
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
license: "CyMaIS NonCommercial License (CNCL)"
license_url: "https://s.veen.world/cncl"
min_ansible_version: "2.9"
platforms:
- name: Archlinux
versions:
- rolling
galaxy_tags:
- nginx
- javascript
- csp
- sub_filter
- injection
- global
repository: "https://s.veen.world/cymais"
documentation: "https://s.veen.world/cymais"
issue_tracker_url: "https://s.veen.world/cymaisissues"
dependencies:
- nginx

View File

@ -0,0 +1,12 @@
- name: "Load JavaScript code for '{{ application_id }}'"
set_fact:
javascript_code: "{{ lookup('template', modifier_javascript_template_file) }}"
- name: "Collapse Javascript code into one-liner for '{{application_id}}'"
set_fact:
javascript_code_one_liner: "{{ javascript_code | to_one_liner }}"
- name: "Append Javascript CSP hash for '{{application_id}}'"
set_fact:
applications: "{{ applications | append_csp_hash(application_id, javascript_code_one_liner) }}"
changed_when: false

View File

@ -0,0 +1 @@
<script>{{ javascript_code_one_liner | replace("'", "\\'") }}</script>

View File

@ -0,0 +1 @@
modifier_javascript_template_file: "{{ playbook_dir }}/roles/docker-{{ application_id }}/templates/javascript.js.j2"

View File

@ -45,45 +45,16 @@
when: "matomo_site_id is not defined or matomo_site_id is none"
changed_when: false
- name: Set the Matomo tracking code from a template file
- name: Load Matomo tracking JS template
set_fact:
matomo_tracking_code: "{{ lookup('template', 'matomo-tracking.js.j2') }}"
matomo_tracking_code: "{{ lookup('template','matomo-tracking.js.j2') }}"
- name: Set the tracking code as a one-liner
- name: Collapse Matomo code into one-liner
set_fact:
matomo_tracking_code_one_liner: "{{ matomo_tracking_code | regex_replace('\\n', '') | regex_replace('\\s+', ' ') }}"
matomo_tracking_code_one_liner: "{{ matomo_tracking_code | to_one_liner }}"
- name: Ensure csp.hashes exists for this app
- name: Append Matomo CSP hash
set_fact:
applications: >-
{{
applications
| combine({
(application_id): {
'csp': {
'hashes': {}
}
}
}, recursive=True)
}}
applications: "{{ applications | append_csp_hash(application_id, matomo_tracking_code_one_liner) }}"
changed_when: false
- name: Append Matomo one-liner to script-src inline hashes
set_fact:
applications: >-
{{
applications
| combine({
(application_id): {
'csp': {
'hashes': {
'script-src-elem': (
applications[application_id]['csp']['hashes'].get('script-src', [])
+ [ matomo_tracking_code_one_liner ]
)
}
}
}
}, recursive=True)
}}
changed_when: false

View File

@ -13,3 +13,7 @@ _paq.push(["enableLinkTracking"]);
var d=document, g=d.createElement("script"), s=d.getElementsByTagName("script")[0];
g.async=true; g.src=u+"matomo.js"; s.parentNode.insertBefore(g,s);
})();
{% if enable_debug | bool %}
console.log("Matomo is loaded.");
{% endif %}

View File

@ -0,0 +1 @@
alert('Custom JS loaded');

View File

@ -16,6 +16,7 @@ features:
central_database: false # Enable Central Database Network
recaptcha: false # Enable ReCaptcha
oauth2: false # Enable the OAuth2-Proy
javascript: false # Enables the custom JS in the javascript.js.j2 file
csp:
whitelist: {} # URL's which should be whitelisted
flags: {} # Flags which should be set

View File

@ -0,0 +1,52 @@
# tests/unit/filter_plugins/test_csp_hashes.py
import unittest
from ansible.errors import AnsibleFilterError
from filter_plugins.csp_hashes import append_csp_hash
class TestCspHashes(unittest.TestCase):
def setUp(self):
# Sample applications dict for testing
self.applications = {
'app1': {
'csp': {
'hashes': {
'script-src-elem': ["existing-hash"]
}
}
}
}
self.code = "new-hash" # example one-liner hash
def test_appends_new_hash(self):
result = append_csp_hash(self.applications, 'app1', self.code)
# Original remains unchanged
self.assertNotIn(self.code, self.applications['app1']['csp']['hashes']['script-src-elem'])
# New result should contain both existing and new
self.assertIn('existing-hash', result['app1']['csp']['hashes']['script-src-elem'])
self.assertIn(self.code, result['app1']['csp']['hashes']['script-src-elem'])
def test_does_not_duplicate_existing_hash(self):
# Append an existing hash
result = append_csp_hash(self.applications, 'app1', 'existing-hash')
# Should still only have one instance
hashes = result['app1']['csp']['hashes']['script-src-elem']
self.assertEqual(hashes.count('existing-hash'), 1)
def test_creates_missing_csp_structure(self):
# Remove csp and hashes keys
apps = {'app2': {}}
result = append_csp_hash(apps, 'app2', self.code)
self.assertIn('csp', result['app2'])
self.assertIn('hashes', result['app2']['csp'])
self.assertIn(self.code, result['app2']['csp']['hashes']['script-src-elem'])
def test_non_dict_applications_raises(self):
with self.assertRaises(AnsibleFilterError):
append_csp_hash('not-a-dict', 'app1', self.code)
def test_unknown_application_id_raises(self):
with self.assertRaises(AnsibleFilterError):
append_csp_hash(self.applications, 'unknown', self.code)
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,64 @@
# tests/unit/filter_plugins/test_text_filters.py
import unittest
from ansible.errors import AnsibleFilterError
from filter_plugins.text_filters import to_one_liner
class TestTextFilters(unittest.TestCase):
def test_collapse_whitespace(self):
s = """Line one
Line two
Line three"""
expected = "Line one Line two Line three"
self.assertEqual(to_one_liner(s), expected)
def test_remove_js_line_comments(self):
s = "var a = 1; // this is a comment\nvar b = 2;"
expected = "var a = 1; var b = 2;"
self.assertEqual(to_one_liner(s), expected)
def test_remove_js_block_comments(self):
s = "var a = /* comment inside */ 1; var b = 2;"
expected = "var a = 1; var b = 2;"
self.assertEqual(to_one_liner(s), expected)
def test_remove_multiple_comments(self):
s = "// first comment\nvar a = 1; /* block comment */ var b = 2; // end comment"
expected = "var a = 1; var b = 2;"
self.assertEqual(to_one_liner(s), expected)
def test_strips_leading_trailing_whitespace(self):
s = " \n some text here \n "
expected = "some text here"
self.assertEqual(to_one_liner(s), expected)
def test_non_string_raises(self):
with self.assertRaises(AnsibleFilterError):
to_one_liner(123)
def test_preserve_urls_in_string_literals(self):
s = 'var url = "http://example.com/path"; // comment'
expected = 'var url = "http://example.com/path";'
self.assertEqual(to_one_liner(s), expected)
def test_preserve_escaped_quotes_and_protocol(self):
s = "var s = 'He said \\'//not comment\\''; // remove this"
expected = "var s = 'He said \\'//not comment\\'';"
self.assertEqual(to_one_liner(s), expected)
def test_preserve_templating_expressions(self):
s = 'var tracker = "//{{ domains | get_domain(\'matomo\') }}/matomo.js"; // loader'
expected = 'var tracker = "//{{ domains | get_domain(\'matomo\') }}/matomo.js";'
self.assertEqual(to_one_liner(s), expected)
def test_mixed_string_and_comment(self):
s = '''
var a = "foo // still part of string";
// top-level comment
var b = 2; // end comment
'''
expected = 'var a = "foo // still part of string"; var b = 2;'
self.assertEqual(to_one_liner(s), expected)
if __name__ == '__main__':
unittest.main()