From 9159a0c7d34da7354aa29395b94fcb46f9eb7f53 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 8 Jul 2025 01:34:18 +0200 Subject: [PATCH] Finished Iframe Implementation --- filter_plugins/csp_hashes.py | 30 +++++++++ filter_plugins/text_filters.py | 44 +++++++++++++ roles/docker-akaunting/vars/configuration.yml | 2 +- roles/docker-attendize/vars/configuration.yml | 2 +- .../docker-portfolio/templates/config.yaml.j2 | 13 ++-- .../templates/javascript.js.j2 | 30 +++++++++ roles/docker-portfolio/vars/configuration.yml | 2 +- roles/nginx-modifier-all/tasks/main.yml | 12 +++- .../templates/global.includes.conf.j2 | 36 +++++++---- .../templates/{link.j2 => head_sub.j2} | 0 roles/nginx-modifier-iframe/README.md | 24 +++++++ roles/nginx-modifier-iframe/meta/main.yml | 28 ++++++++ roles/nginx-modifier-iframe/tasks/main.yml | 12 ++++ .../templates/head_sub.j2 | 1 + .../templates/iframe-handler.js.j2 | 46 +++++++++++++ roles/nginx-modifier-javascript/README.md | 28 ++++++++ roles/nginx-modifier-javascript/meta/main.yml | 28 ++++++++ .../nginx-modifier-javascript/tasks/main.yml | 12 ++++ .../templates/head_sub.j2 | 1 + roles/nginx-modifier-javascript/vars/main.yml | 1 + roles/nginx-modifier-matomo/tasks/main.yml | 41 ++---------- .../templates/{script.j2 => head_sub.j2} | 0 .../templates/matomo-tracking.js.j2 | 4 ++ .../docker_role/templates/javascript.js.j2 | 1 + .../docker_role/vars/configuration.yml.j2 | 1 + tests/unit/filter_plugins/test_csp_hashes.py | 52 +++++++++++++++ .../unit/filter_plugins/test_text_filters.py | 64 +++++++++++++++++++ 27 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 filter_plugins/csp_hashes.py create mode 100644 filter_plugins/text_filters.py create mode 100644 roles/docker-portfolio/templates/javascript.js.j2 rename roles/nginx-modifier-css/templates/{link.j2 => head_sub.j2} (100%) create mode 100644 roles/nginx-modifier-iframe/README.md create mode 100644 roles/nginx-modifier-iframe/meta/main.yml create mode 100644 roles/nginx-modifier-iframe/tasks/main.yml create mode 100644 roles/nginx-modifier-iframe/templates/head_sub.j2 create mode 100644 roles/nginx-modifier-iframe/templates/iframe-handler.js.j2 create mode 100644 roles/nginx-modifier-javascript/README.md create mode 100644 roles/nginx-modifier-javascript/meta/main.yml create mode 100644 roles/nginx-modifier-javascript/tasks/main.yml create mode 100644 roles/nginx-modifier-javascript/templates/head_sub.j2 create mode 100644 roles/nginx-modifier-javascript/vars/main.yml rename roles/nginx-modifier-matomo/templates/{script.j2 => head_sub.j2} (100%) create mode 100644 templates/docker_role/templates/javascript.js.j2 create mode 100644 tests/unit/filter_plugins/test_csp_hashes.py create mode 100644 tests/unit/filter_plugins/test_text_filters.py diff --git a/filter_plugins/csp_hashes.py b/filter_plugins/csp_hashes.py new file mode 100644 index 00000000..f1bb8491 --- /dev/null +++ b/filter_plugins/csp_hashes.py @@ -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 + } diff --git a/filter_plugins/text_filters.py b/filter_plugins/text_filters.py new file mode 100644 index 00000000..88ae3295 --- /dev/null +++ b/filter_plugins/text_filters.py @@ -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, + } diff --git a/roles/docker-akaunting/vars/configuration.yml b/roles/docker-akaunting/vars/configuration.yml index 9290b760..6e56e5a0 100644 --- a/roles/docker-akaunting/vars/configuration.yml +++ b/roles/docker-akaunting/vars/configuration.yml @@ -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: diff --git a/roles/docker-attendize/vars/configuration.yml b/roles/docker-attendize/vars/configuration.yml index 84ce7fee..18979832 100644 --- a/roles/docker-attendize/vars/configuration.yml +++ b/roles/docker-attendize/vars/configuration.yml @@ -5,7 +5,7 @@ credentials: features: matomo: true css: true - portfolio_iframe: false + portfolio_iframe: true central_database: true docker: services: diff --git a/roles/docker-portfolio/templates/config.yaml.j2 b/roles/docker-portfolio/templates/config.yaml.j2 index 8f02288b..6ea27af4 100644 --- a/roles/docker-portfolio/templates/config.yaml.j2 +++ b/roles/docker-portfolio/templates/config.yaml.j2 @@ -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' %} \ No newline at end of file diff --git a/roles/docker-portfolio/templates/javascript.js.j2 b/roles/docker-portfolio/templates/javascript.js.j2 new file mode 100644 index 00000000..2577a75a --- /dev/null +++ b/roles/docker-portfolio/templates/javascript.js.j2 @@ -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 %} diff --git a/roles/docker-portfolio/vars/configuration.yml b/roles/docker-portfolio/vars/configuration.yml index 2a7ac7ac..fd722b20 100644 --- a/roles/docker-portfolio/vars/configuration.yml +++ b/roles/docker-portfolio/vars/configuration.yml @@ -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: diff --git a/roles/nginx-modifier-all/tasks/main.yml b/roles/nginx-modifier-all/tasks/main.yml index b63a230c..97051d55 100644 --- a/roles/nginx-modifier-all/tasks/main.yml +++ b/roles/nginx-modifier-all/tasks/main.yml @@ -6,4 +6,14 @@ - name: "Activate Global Matomo Tracking for {{domain}}" include_role: name: nginx-modifier-matomo - when: applications | is_feature_enabled('matomo',application_id) \ No newline at end of file + 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) \ No newline at end of file diff --git a/roles/nginx-modifier-all/templates/global.includes.conf.j2 b/roles/nginx-modifier-all/templates/global.includes.conf.j2 index 2f4d5d99..729ba2e7 100644 --- a/roles/nginx-modifier-all/templates/global.includes.conf.j2 +++ b/roles/nginx-modifier-all/templates/global.includes.conf.j2 @@ -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 '' ' + {%- 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 -%} + '; {% endif %} -{% if features_css_final | bool or features_matomo_final | bool %} - sub_filter '' '{% 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 %}'; +{% 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' %} -{% endif %} +{% if modifier_matomo_enabled %} +{% include 'roles/nginx-modifier-matomo/templates/matomo-tracking.conf.j2' %} +{% endif %} \ No newline at end of file diff --git a/roles/nginx-modifier-css/templates/link.j2 b/roles/nginx-modifier-css/templates/head_sub.j2 similarity index 100% rename from roles/nginx-modifier-css/templates/link.j2 rename to roles/nginx-modifier-css/templates/head_sub.j2 diff --git a/roles/nginx-modifier-iframe/README.md b/roles/nginx-modifier-iframe/README.md new file mode 100644 index 00000000..0078afbe --- /dev/null +++ b/roles/nginx-modifier-iframe/README.md @@ -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 iframe’s 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) 🎉 \ No newline at end of file diff --git a/roles/nginx-modifier-iframe/meta/main.yml b/roles/nginx-modifier-iframe/meta/main.yml new file mode 100644 index 00000000..24cf628c --- /dev/null +++ b/roles/nginx-modifier-iframe/meta/main.yml @@ -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 \ No newline at end of file diff --git a/roles/nginx-modifier-iframe/tasks/main.yml b/roles/nginx-modifier-iframe/tasks/main.yml new file mode 100644 index 00000000..238cbd3f --- /dev/null +++ b/roles/nginx-modifier-iframe/tasks/main.yml @@ -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 diff --git a/roles/nginx-modifier-iframe/templates/head_sub.j2 b/roles/nginx-modifier-iframe/templates/head_sub.j2 new file mode 100644 index 00000000..1ba92d16 --- /dev/null +++ b/roles/nginx-modifier-iframe/templates/head_sub.j2 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/roles/nginx-modifier-iframe/templates/iframe-handler.js.j2 b/roles/nginx-modifier-iframe/templates/iframe-handler.js.j2 new file mode 100644 index 00000000..89388065 --- /dev/null +++ b/roles/nginx-modifier-iframe/templates/iframe-handler.js.j2 @@ -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 %} \ No newline at end of file diff --git a/roles/nginx-modifier-javascript/README.md b/roles/nginx-modifier-javascript/README.md new file mode 100644 index 00000000..0ccac899 --- /dev/null +++ b/roles/nginx-modifier-javascript/README.md @@ -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 Nginx’s `sub_filter` to seamlessly insert your application-specific script just before the closing `` 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! 🎉 diff --git a/roles/nginx-modifier-javascript/meta/main.yml b/roles/nginx-modifier-javascript/meta/main.yml new file mode 100644 index 00000000..b70d0241 --- /dev/null +++ b/roles/nginx-modifier-javascript/meta/main.yml @@ -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 diff --git a/roles/nginx-modifier-javascript/tasks/main.yml b/roles/nginx-modifier-javascript/tasks/main.yml new file mode 100644 index 00000000..ea0842ca --- /dev/null +++ b/roles/nginx-modifier-javascript/tasks/main.yml @@ -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 diff --git a/roles/nginx-modifier-javascript/templates/head_sub.j2 b/roles/nginx-modifier-javascript/templates/head_sub.j2 new file mode 100644 index 00000000..1731b48b --- /dev/null +++ b/roles/nginx-modifier-javascript/templates/head_sub.j2 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/roles/nginx-modifier-javascript/vars/main.yml b/roles/nginx-modifier-javascript/vars/main.yml new file mode 100644 index 00000000..fcc5896a --- /dev/null +++ b/roles/nginx-modifier-javascript/vars/main.yml @@ -0,0 +1 @@ +modifier_javascript_template_file: "{{ playbook_dir }}/roles/docker-{{ application_id }}/templates/javascript.js.j2" \ No newline at end of file diff --git a/roles/nginx-modifier-matomo/tasks/main.yml b/roles/nginx-modifier-matomo/tasks/main.yml index 99bd42d6..a6886d1c 100644 --- a/roles/nginx-modifier-matomo/tasks/main.yml +++ b/roles/nginx-modifier-matomo/tasks/main.yml @@ -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 diff --git a/roles/nginx-modifier-matomo/templates/script.j2 b/roles/nginx-modifier-matomo/templates/head_sub.j2 similarity index 100% rename from roles/nginx-modifier-matomo/templates/script.j2 rename to roles/nginx-modifier-matomo/templates/head_sub.j2 diff --git a/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2 b/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2 index 7ef04580..f95df441 100644 --- a/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2 +++ b/roles/nginx-modifier-matomo/templates/matomo-tracking.js.j2 @@ -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 %} \ No newline at end of file diff --git a/templates/docker_role/templates/javascript.js.j2 b/templates/docker_role/templates/javascript.js.j2 new file mode 100644 index 00000000..5a1bd867 --- /dev/null +++ b/templates/docker_role/templates/javascript.js.j2 @@ -0,0 +1 @@ +alert('Custom JS loaded'); \ No newline at end of file diff --git a/templates/docker_role/vars/configuration.yml.j2 b/templates/docker_role/vars/configuration.yml.j2 index 2a35308f..acb29542 100644 --- a/templates/docker_role/vars/configuration.yml.j2 +++ b/templates/docker_role/vars/configuration.yml.j2 @@ -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 diff --git a/tests/unit/filter_plugins/test_csp_hashes.py b/tests/unit/filter_plugins/test_csp_hashes.py new file mode 100644 index 00000000..e5cde0c8 --- /dev/null +++ b/tests/unit/filter_plugins/test_csp_hashes.py @@ -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() diff --git a/tests/unit/filter_plugins/test_text_filters.py b/tests/unit/filter_plugins/test_text_filters.py new file mode 100644 index 00000000..85f07a58 --- /dev/null +++ b/tests/unit/filter_plugins/test_text_filters.py @@ -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()