mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-04 04:08:15 +00:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			a100c9e63d
			...
			e729706ec6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e729706ec6 | |||
| 9159a0c7d3 | 
							
								
								
									
										30
									
								
								filter_plugins/csp_hashes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								filter_plugins/csp_hashes.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
        }
 | 
			
		||||
							
								
								
									
										44
									
								
								filter_plugins/text_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								filter_plugins/text_filters.py
									
									
									
									
									
										Normal 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,
 | 
			
		||||
        }
 | 
			
		||||
@@ -6,9 +6,8 @@ setup_admin_email:  "{{ users.administrator.email }}"
 | 
			
		||||
features:
 | 
			
		||||
  matomo:           true
 | 
			
		||||
  css:              true
 | 
			
		||||
  portfolio_iframe: false
 | 
			
		||||
  portfolio_iframe: true
 | 
			
		||||
  central_database: true
 | 
			
		||||
credentials:
 | 
			
		||||
domains:
 | 
			
		||||
  canonical:
 | 
			
		||||
    - "accounting.{{ primary_domain }}"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,10 @@
 | 
			
		||||
image:              
 | 
			
		||||
  web:              "attendize_web:latest"
 | 
			
		||||
  worker:           "attendize_worker:latest"
 | 
			
		||||
credentials:
 | 
			
		||||
features:
 | 
			
		||||
  matomo:           true
 | 
			
		||||
  css:              true
 | 
			
		||||
  portfolio_iframe: false
 | 
			
		||||
  portfolio_iframe: true
 | 
			
		||||
  central_database: true
 | 
			
		||||
docker:
 | 
			
		||||
  services:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
enable_greenlight:    "true"
 | 
			
		||||
setup:                false
 | 
			
		||||
credentials:
 | 
			
		||||
database:
 | 
			
		||||
  name:               "multiple_databases"
 | 
			
		||||
  username:           "postgres2"
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ images:
 | 
			
		||||
  pds:              "ghcr.io/bluesky-social/pds:latest"
 | 
			
		||||
pds:
 | 
			
		||||
  version:          "latest"
 | 
			
		||||
credentials:
 | 
			
		||||
features:
 | 
			
		||||
  matomo:           true
 | 
			
		||||
  css:              true
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
network:    "discourse_default"     # Name of the docker network
 | 
			
		||||
container:  "discourse_application" # Name of the container application
 | 
			
		||||
repository: "discourse_repository"  # Name of the repository folder
 | 
			
		||||
credentials:                    
 | 
			
		||||
repository: "discourse_repository"  # Name of the repository folder                 
 | 
			
		||||
features:
 | 
			
		||||
  matomo:             true
 | 
			
		||||
  css:                true
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,8 @@ oauth2_proxy:
 | 
			
		||||
  application:        "application"
 | 
			
		||||
  port:               "80"
 | 
			
		||||
addons:
 | 
			
		||||
  keycloakpassword:
 | 
			
		||||
  ldapauth:
 | 
			
		||||
  keycloakpassword: {}
 | 
			
		||||
  ldapauth: {}
 | 
			
		||||
docker:
 | 
			
		||||
  services:
 | 
			
		||||
    database:
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,6 @@ features:
 | 
			
		||||
  ldap:               true
 | 
			
		||||
  central_database:   true
 | 
			
		||||
  oauth2:             false # Doesn't make sense to activate it atm, because login is possible on homepage
 | 
			
		||||
credentials:
 | 
			
		||||
domains:
 | 
			
		||||
  canonical:
 | 
			
		||||
    - "audio.{{ primary_domain }}"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
images: 
 | 
			
		||||
  keycloak:           "quay.io/keycloak/keycloak:latest"
 | 
			
		||||
import_realm:         True                                # If True realm will be imported. If false skip.
 | 
			
		||||
credentials:
 | 
			
		||||
features:
 | 
			
		||||
  matomo:             true
 | 
			
		||||
  css:                false
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ images:
 | 
			
		||||
oauth2_proxy:
 | 
			
		||||
  application:                  application
 | 
			
		||||
  port:                         80
 | 
			
		||||
credentials:
 | 
			
		||||
features:
 | 
			
		||||
  matomo:                       true
 | 
			
		||||
  css:                          true
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,5 @@ network:
 | 
			
		||||
  public:         False                               # Set to true in inventory file if you want to expose the LDAP port to the internet
 | 
			
		||||
hostname:         "ldap"                              # Hostname of the LDAP Server in the central_ldap network
 | 
			
		||||
webinterface:     "lam"                               # The webinterface which should be used. Possible: lam and phpldapadmin
 | 
			
		||||
credentials:
 | 
			
		||||
features:
 | 
			
		||||
  ldap:           true
 | 
			
		||||
@@ -2,8 +2,7 @@ version:                  "2024.06"                         # Docker Image Versi
 | 
			
		||||
oidc:
 | 
			
		||||
  email_by_username:      true                              # If true, then the mail is set by the username. If wrong then the OIDC user email is used
 | 
			
		||||
  enable_user_creation:   true                              # Users will be created if not existing
 | 
			
		||||
domain:                   "{{primary_domain}}"              # The main domain from which mails will be send \ email suffix behind @
 | 
			
		||||
credentials:                                     
 | 
			
		||||
domain:                   "{{primary_domain}}"              # The main domain from which mails will be send \ email suffix behind @                              
 | 
			
		||||
features:
 | 
			
		||||
  matomo:                 true
 | 
			
		||||
  css:                    false
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,7 @@ images:
 | 
			
		||||
  mastodon:           "ghcr.io/mastodon/mastodon:latest"
 | 
			
		||||
  streaming:          "ghcr.io/mastodon/mastodon-streaming:latest"
 | 
			
		||||
single_user_mode:     false                                           # Set true for initial setup
 | 
			
		||||
setup:                false                                           # Set true in inventory file to execute the setup and initializing procedures
 | 
			
		||||
credentials:                            
 | 
			
		||||
setup:                false                                           # Set true in inventory file to execute the setup and initializing procedures                         
 | 
			
		||||
features:
 | 
			
		||||
  matomo:             true
 | 
			
		||||
  css:                true
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,6 @@ oidc:
 | 
			
		||||
  # @see https://apps.nextcloud.com/apps/oidc_login
 | 
			
		||||
  # @see https://apps.nextcloud.com/apps/sociallogin
 | 
			
		||||
  flavor:                     "oidc_login"                                      # Keeping on sociallogin because the other option is not implemented yet                                             
 | 
			
		||||
credentials:
 | 
			
		||||
features:
 | 
			
		||||
  matomo:                       true
 | 
			
		||||
  css:                          false
 | 
			
		||||
 
 | 
			
		||||
@@ -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' %}
 | 
			
		||||
							
								
								
									
										30
									
								
								roles/docker-portfolio/templates/javascript.js.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								roles/docker-portfolio/templates/javascript.js.j2
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ features:
 | 
			
		||||
  central_database: false   # Enable Central Database Network
 | 
			
		||||
  recaptcha:        false   # Enable ReCaptcha
 | 
			
		||||
  oauth2:           false   # Enable the OAuth2-Proy
 | 
			
		||||
csp:                        
 | 
			
		||||
csp: {}      
 | 
			
		||||
domains:
 | 
			
		||||
  canonical:
 | 
			
		||||
    - "icons.{{ primary_domain }}"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
version:              "latest"
 | 
			
		||||
oidc:
 | 
			
		||||
oidc: {}
 | 
			
		||||
    # Taiga doesn't have a functioning oidc support at the moment
 | 
			
		||||
    # See
 | 
			
		||||
    # - https://community.taiga.io/t/taiga-and-oidc-plugin/4866
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
  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)
 | 
			
		||||
@@ -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' %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if modifier_matomo_enabled %}
 | 
			
		||||
{% include 'roles/nginx-modifier-matomo/templates/matomo-tracking.conf.j2' %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
							
								
								
									
										24
									
								
								roles/nginx-modifier-iframe/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								roles/nginx-modifier-iframe/README.md
									
									
									
									
									
										Normal 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 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) 🎉
 | 
			
		||||
							
								
								
									
										28
									
								
								roles/nginx-modifier-iframe/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								roles/nginx-modifier-iframe/meta/main.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										12
									
								
								roles/nginx-modifier-iframe/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								roles/nginx-modifier-iframe/tasks/main.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										1
									
								
								roles/nginx-modifier-iframe/templates/head_sub.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								roles/nginx-modifier-iframe/templates/head_sub.j2
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<script>{{ iframe_code_one_liner | replace("'", "\\'") }}</script>
 | 
			
		||||
							
								
								
									
										46
									
								
								roles/nginx-modifier-iframe/templates/iframe-handler.js.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								roles/nginx-modifier-iframe/templates/iframe-handler.js.j2
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
							
								
								
									
										28
									
								
								roles/nginx-modifier-javascript/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								roles/nginx-modifier-javascript/README.md
									
									
									
									
									
										Normal 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 Nginx’s `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! 🎉
 | 
			
		||||
							
								
								
									
										28
									
								
								roles/nginx-modifier-javascript/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								roles/nginx-modifier-javascript/meta/main.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										12
									
								
								roles/nginx-modifier-javascript/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								roles/nginx-modifier-javascript/tasks/main.yml
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										1
									
								
								roles/nginx-modifier-javascript/templates/head_sub.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								roles/nginx-modifier-javascript/templates/head_sub.j2
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
<script>{{ javascript_code_one_liner | replace("'", "\\'")  }}</script>
 | 
			
		||||
							
								
								
									
										1
									
								
								roles/nginx-modifier-javascript/vars/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								roles/nginx-modifier-javascript/vars/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
modifier_javascript_template_file: "{{ playbook_dir }}/roles/docker-{{ application_id }}/templates/javascript.js.j2"
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
							
								
								
									
										1
									
								
								templates/docker_role/templates/javascript.js.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								templates/docker_role/templates/javascript.js.j2
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
alert('Custom JS loaded');
 | 
			
		||||
@@ -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 
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										61
									
								
								tests/integration/test_configuration_non_empty.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								tests/integration/test_configuration_non_empty.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
import os
 | 
			
		||||
import glob
 | 
			
		||||
import yaml
 | 
			
		||||
import unittest
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def find_none_values(data, prefix=None):
 | 
			
		||||
    """
 | 
			
		||||
    Recursively find keys with None values in a nested dict or list.
 | 
			
		||||
    Returns a list of (path, value) tuples where value is None.
 | 
			
		||||
    """
 | 
			
		||||
    errors = []
 | 
			
		||||
    if prefix is None:
 | 
			
		||||
        prefix = []
 | 
			
		||||
 | 
			
		||||
    if isinstance(data, dict):
 | 
			
		||||
        for key, value in data.items():
 | 
			
		||||
            path = prefix + [str(key)]
 | 
			
		||||
            if value is None:
 | 
			
		||||
                errors.append((".".join(path), value))
 | 
			
		||||
            elif isinstance(value, (dict, list)):
 | 
			
		||||
                errors.extend(find_none_values(value, path))
 | 
			
		||||
    elif isinstance(data, list):
 | 
			
		||||
        for idx, item in enumerate(data):
 | 
			
		||||
            path = prefix + [f"[{idx}]"]
 | 
			
		||||
            if item is None:
 | 
			
		||||
                errors.append((".".join(path), item))
 | 
			
		||||
            elif isinstance(item, (dict, list)):
 | 
			
		||||
                errors.extend(find_none_values(item, path))
 | 
			
		||||
 | 
			
		||||
    return errors
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestConfigurationNoNone(unittest.TestCase):
 | 
			
		||||
    def test_configuration_files_have_no_none_values(self):
 | 
			
		||||
        # Find all configuration.yml files under roles/*/vars
 | 
			
		||||
        pattern = os.path.join(
 | 
			
		||||
            os.path.dirname(__file__),
 | 
			
		||||
            os.pardir, os.pardir,
 | 
			
		||||
            'roles', '*', 'vars', 'configuration.yml'
 | 
			
		||||
        )
 | 
			
		||||
        files = glob.glob(pattern)
 | 
			
		||||
        self.assertTrue(files, f"No configuration.yml files found with pattern: {pattern}")
 | 
			
		||||
 | 
			
		||||
        all_errors = []
 | 
			
		||||
        for filepath in files:
 | 
			
		||||
            with open(filepath, 'r') as f:
 | 
			
		||||
                try:
 | 
			
		||||
                    data = yaml.safe_load(f)
 | 
			
		||||
                except yaml.YAMLError as e:
 | 
			
		||||
                    self.fail(f"Failed to parse YAML in {filepath}: {e}")
 | 
			
		||||
            errors = find_none_values(data)
 | 
			
		||||
            for path, value in errors:
 | 
			
		||||
                all_errors.append(f"{filepath}: Key '{path}' is None")
 | 
			
		||||
 | 
			
		||||
        if all_errors:
 | 
			
		||||
            self.fail("None values found in configuration files:\n" + "\n".join(all_errors))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    unittest.main()
 | 
			
		||||
							
								
								
									
										52
									
								
								tests/unit/filter_plugins/test_csp_hashes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								tests/unit/filter_plugins/test_csp_hashes.py
									
									
									
									
									
										Normal 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()
 | 
			
		||||
							
								
								
									
										64
									
								
								tests/unit/filter_plugins/test_text_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								tests/unit/filter_plugins/test_text_filters.py
									
									
									
									
									
										Normal 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()
 | 
			
		||||
		Reference in New Issue
	
	Block a user