feat(frontend): rename inj roles to sys-front-*, add sys-svc-cdn, cache-busting lookup

Introduce sys-svc-cdn (cdn_paths/cdn_urls/cdn_dirs) and ensure CDN directories + latest symlink.

Rename sys-srv-web-inj-* → sys-front-inj-*; update includes/templates; serve shared/per-app CSS & JS via CDN.

Add lookup_plugins/local_mtime_qs.py for mtime-based cache busting; split CSS into default.css/bootstrap.css + optional per-app style.css.

CSP: use style-src-elem; drop unsafe-inline for styles. Services: fix SYS_SERVICE_ALL_ENABLED bool and controlled flush.

BREAKING CHANGE: role names changed; replace includes and references accordingly.

Conversation: https://chatgpt.com/share/68b55494-9ec4-800f-b559-44707029141d
This commit is contained in:
2025-09-01 10:10:23 +02:00
parent 3f8e7c1733
commit 231fd567b3
123 changed files with 1789 additions and 1393 deletions

View File

@@ -0,0 +1,29 @@
# Nginx Global Matomo & Theming Modifier Role 🚀
This role enhances your Nginx configuration by conditionally injecting global Matomo tracking and theming elements into your HTML responses. It uses Nginx sub-filters to seamlessly add tracking scripts and CSS links to your web pages.
---
## Features
- **Global Matomo Tracking**
The role includes Matomo tracking configuration and injects the corresponding tracking script into your HTML.
- **Global Theming**
The role injects a global CSS link for consistent theming across your site.
- **Smart Injection**
Uses Nginx's `sub_filter` to insert the tracking and theming snippets right before the closing `</head>` tag of your HTML documents.
This will automatically activate Matomo tracking and/or global theming based on your configuration.
---
## Author
Developed by [Kevin Veen-Birkenbach](https://www.veen.world) 😎
---
Happy automating! 🎉

View File

View File

@@ -0,0 +1,34 @@
#
# Usage in tasks:
# - set_fact:
# inj_enabled: "{{ applications | inj_enabled(application_id, ['javascript','logout','css','matomo','desktop']) }}"
import sys
import os
# allow imports from module_utils (same trick as your get_app_conf filter)
base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
mu = os.path.join(base, 'module_utils')
for p in (base, mu):
if p not in sys.path:
sys.path.insert(0, p)
from module_utils.config_utils import get_app_conf
def inj_enabled_filter(applications, application_id, features, prefix="features", default=False):
"""
Build a dict {feature: value} by reading the feature flags under the given prefix for the selected application.
Uses get_app_conf with strict=False so missing keys just return the default.
"""
result = {}
for f in features:
path = f"{prefix}.{f}" if prefix else f
result[f] = get_app_conf(applications, application_id, path, strict=False, default=default)
return result
class FilterModule(object):
def filters(self):
return {
"inj_enabled": inj_enabled_filter,
}

View File

@@ -0,0 +1,55 @@
"""
Jinja filter: `inj_features(kind)` filters a list of features to only those
that actually provide the corresponding snippet template file.
- kind='head' -> roles/sys-front-inj-<feature>/templates/head_sub.j2
- kind='body' -> roles/sys-front-inj-<feature>/templates/body_sub.j2
If the feature's role directory (roles/sys-front-inj-<feature>) does not
exist, this filter raises FileNotFoundError.
Usage in a template:
{% set head_features = SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('head') %}
{% set body_features = SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('body') %}
"""
import os
# This file lives at: roles/sys-front-inj-all/filter_plugins/inj_snippets.py
_THIS_DIR = os.path.dirname(__file__)
_ROLE_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..")) # roles/sys-front-inj-all
_ROLES_DIR = os.path.abspath(os.path.join(_ROLE_DIR, "..")) # roles
def _feature_role_dir(feature: str) -> str:
return os.path.join(_ROLES_DIR, f"sys-front-inj-{feature}")
def _has_snippet(feature: str, kind: str) -> bool:
if kind not in ("head", "body"):
raise ValueError("kind must be 'head' or 'body'")
role_dir = _feature_role_dir(feature)
if not os.path.isdir(role_dir):
raise FileNotFoundError(
f"[inj_snippets] Expected role directory not found for feature "
f"'{feature}': {role_dir}"
)
path = os.path.join(role_dir, "templates", f"{kind}_sub.j2")
return os.path.exists(path)
def inj_features_filter(features, kind: str = "head"):
if not isinstance(features, (list, tuple)):
return []
# Validation + filtering in one pass; will raise if a role dir is missing.
valid = []
for f in features:
name = str(f)
if _has_snippet(name, kind):
valid.append(name)
return valid
class FilterModule(object):
def filters(self):
return {
"inj_features": inj_features_filter,
}

View File

@@ -0,0 +1,22 @@
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Core role for Nginx HTML injection of Matomo, theming, iFrame and JS snippets based on application feature flags."
license: "Infinito.Nexus NonCommercial License"
license_url: "https://s.infinito.nexus/license"
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
galaxy_tags:
- nginx
- injector
- matomo
- theming
repository: "https://s.infinito.nexus/code"
issue_tracker_url: "https://s.infinito.nexus/issues"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-all"
min_ansible_version: "2.9"
platforms:
- name: Any
versions:
- all

View File

@@ -0,0 +1,49 @@
- name: Build inj_enabled
set_fact:
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
- name: "Load CDN Service for '{{ domain }}'"
include_role:
name: sys-svc-cdn
public: true # Expose variables so that they can be used in all injection roles
- name: Reinitialize 'inj_enabled' for '{{ domain }}', after modification by CDN
set_fact:
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
inj_head_features: "{{ SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('head') }}"
inj_body_features: "{{ SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('body') }}"
- name: "Activate Desktop iFrame notifier for '{{ domain }}'"
include_role:
name: sys-front-inj-desktop
public: true # Vars used in templates
when: inj_enabled.desktop
- name: "Activate Corporate CSS for '{{ domain }}'"
include_role:
name: sys-front-inj-css
when: inj_enabled.css
- name: "Activate Matomo Tracking for '{{ domain }}'"
include_role:
name: sys-front-inj-matomo
when: inj_enabled.matomo
- name: "Activate Javascript for '{{ domain }}'"
include_role:
name: sys-front-inj-javascript
when: inj_enabled.javascript
- name: "Activate logout proxy for '{{ domain }}'"
include_role:
name: sys-front-inj-logout
public: true # Vars used in templates
when: inj_enabled.logout
- block:
- name: Include dependency 'srv-core'
include_role:
name: srv-core
when: run_once_srv_core is not defined
- include_tasks: utils/run_once.yml
when: run_once_sys_front_inj_all is not defined

View File

@@ -0,0 +1,91 @@
{# Jinja macro: expands feature snippets into Lua array pushes at render time #}
{% macro push_snippets(list_name, features) -%}
{% set kind = list_name | regex_replace('_snippets$','') %}
{% for f in features if inj_enabled.get(f) -%}
{{ list_name }}[#{{ list_name }} + 1] = [=[
{%- include 'roles/sys-front-inj-' ~ f ~ '/templates/' ~ kind ~ '_sub.j2' -%}
]=]
{% endfor -%}
{%- endmacro %}
lua_need_request_body on;
header_filter_by_lua_block {
local ct = ngx.header.content_type or ""
if ct:lower():find("^text/html") then
ngx.ctx.is_html = true
-- IMPORTANT: body will be modified → drop Content-Length to avoid mismatches
ngx.header.content_length = nil
else
ngx.ctx.is_html = false
end
}
body_filter_by_lua_block {
-- Only process HTML responses
if not ngx.ctx.is_html then
return
end
-- Buffer all chunks until EOF
ngx.ctx.buf = ngx.ctx.buf or {}
local chunk, eof = ngx.arg[1], ngx.arg[2]
if chunk ~= "" then
table.insert(ngx.ctx.buf, chunk)
end
if not eof then
-- Swallow intermediate chunks; emit once at EOF
ngx.arg[1] = nil
return
end
-- Concatenate the full HTML
local whole = table.concat(ngx.ctx.buf)
ngx.ctx.buf = nil
-- Remove inline CSP <meta http-equiv="Content-Security-Policy"> (case-insensitive)
local meta_re = [[<meta[^>]+http-equiv=["']Content-Security-Policy["'][^>]*>\s*]]
whole = ngx.re.gsub(whole, meta_re, "", "ijo")
-- Build head snippets (rendered by Jinja at template time)
local head_snippets = {}
{{ push_snippets('head_snippets', inj_head_features) }}
local head_payload = table.concat(head_snippets, "\n") .. "</head>"
-- Inject before </head> (first occurrence)
local function repl_head(_) return head_payload end
local new, n, err = ngx.re.sub(whole, [[</head\s*>]], repl_head, "ijo")
if new then
whole = new
else
ngx.log(ngx.WARN, "No </head> found; trying <body> fallback: ", err or "nil")
-- Fallback: inject right AFTER the opening <body ...> tag
local body_open_re = [[<body\b[^>]*>]]
new, n, err = ngx.re.sub(whole, body_open_re, "$0\n" .. table.concat(head_snippets, "\n"), "ijo")
if new then
whole = new
else
ngx.log(ngx.ERR, "Head-fallback failed: ", err or "nil")
end
end
-- Build body snippets (rendered by Jinja at template time)
local body_snippets = {}
{{ push_snippets('body_snippets', inj_body_features) }}
local body_payload = table.concat(body_snippets, "\n") .. "</body>"
-- Inject before </body> (first occurrence), or append if missing
local function repl_body(_) return body_payload end
new, n, err = ngx.re.sub(whole, [[</body\s*>]], repl_body, "ijo")
if new then
whole = new
else
ngx.log(ngx.WARN, "No </body> found; appending body snippets at end: ", err or "nil")
whole = whole .. table.concat(body_snippets, "\n")
end
-- Emit the modified HTML
ngx.arg[1] = whole or ""
}

View File

@@ -0,0 +1,3 @@
{% if inj_enabled.logout %}
{% include 'roles/web-svc-logout/templates/logout-proxy.conf.j2' %}
{% endif %}

View File

@@ -0,0 +1,9 @@
# Docker
docker_pull_git_repository: false # Deactivated here to don't inhire this
SRV_WEB_INJ_COMP_FEATURES_ALL:
- 'javascript'
- 'logout'
- 'css'
- 'matomo'
- 'desktop'