Compare commits

...

12 Commits

Author SHA1 Message Date
9f734dff17 web-app-pretix: fix healthcheck and allowed hosts
- Add Host header to curl healthcheck when container_hostname is defined
- Use PRETIX_PRETIX_ALLOWED_HOSTS to fix Django 400 Bad Request during healthcheck
- Centralize PRETIX_HOSTNAME from container_hostname var
- Add Redis broker/result backend config for Celery

See: https://chatgpt.com/share/68b59c42-c0fc-800f-9bfb-f1137c59b3de
2025-09-01 15:15:04 +02:00
6fa4d00547 Refactor CDN and run_once handling
- Move run_once include from main.yml to 01_core.yml in desk-gnome-caffeine and desk-ssh
- Introduce sys-svc-cdn/01_core.yml to handle shared/vendor dirs once and role dirs per run
- Replace cdn.* with cdn_paths_all.* across inj roles
- Split cdn_dirs into cdn_dirs_role and CDN_DIRS_GLOBAL
- Ensure cdn_urls uses cdn_paths_all

Details: https://chatgpt.com/share/68b58d64-1e28-800f-8907-36926a9e9a9b
2025-09-01 14:11:36 +02:00
7254667186 Nextcloud: make app:update more robust by retrying once with retries/until (fixes transient migration errors)
See: https://chatgpt.com/share/68b57e29-4420-800f-b326-b34d09fa64b5
2025-09-01 13:06:44 +02:00
aaedaab3da refactor(web-app-mediawiki): unify debug & oidc handling via _ensure_require, introduce host-side prep, switch to bind mounts
- Removed obsolete Installation.md, TODO.md, 02_debug.yml, 05_oidc.yml and legacy debug enable/disable tasks
- Added 01_prep.yml to render debug.php/oidc.php on host side before container start
- Introduced _ensure_require.yml for generic require_once management in LocalSettings.php
- Renamed 01_install.yml -> 02_install.yml to align with new numbering
- Updated docker-compose.yml.j2 to bind-mount mw-local into /opt/mw-local
- Adjusted vars/main.yml to define MEDIAWIKI_LOCAL_MOUNT_DIR and MEDIAWIKI_LOCAL_PATH
- Templates debug.php.j2 and oidc.php.j2 now gated by MODE_DEBUG and MEDIAWIKI_OIDC_ENABLED
- main.yml now orchestrates prep, install, debug, extensions, oidc require, admin consistently

Ref: https://chatgpt.com/share/68b57db2-efcc-800f-a733-aca952298437
2025-09-01 13:04:57 +02:00
7791bd8c04 Implement filter checks: ensure all defined filters are used and remove dead code
Integration tests added/updated:
- tests/integration/test_filters_usage.py: AST-based detection of filter definitions (FilterModule.filters), robust Jinja detection ({{ ... }}, {% ... %}, {% filter ... %}), plus Python call tracking; fails if a filter is used only under tests/.
- tests/integration/test_filters_are_defined.py: inverse check — every filter used in .yml/.yaml/.j2/.jinja2/.tmpl must be defined locally. Scans only inside Jinja blocks and ignores pipes inside strings (e.g., lookup('pipe', "... | grep ... | awk ...")) to avoid false positives like trusted_hosts, woff/woff2, etc.

Bug fixes & robustness:
- Build regexes without %-string formatting to avoid ValueError from literal '%' in Jinja tags.
- Strip quoted strings in usage analysis so sed/grep/awk pipes are not miscounted as filters.
- Prevent self-matches in the defining file.

Cleanup / removal of dead code:
- Removed unused filter plugins and related unit tests:
  * filter_plugins/alias_domains_map.py
  * filter_plugins/get_application_id.py
  * filter_plugins/load_configuration.py
  * filter_plugins/safe.py
  * filter_plugins/safe_join.py
  * roles/svc-db-openldap/filter_plugins/build_ldap_nested_group_entries.py
  * roles/sys-ctl-bkp-docker-2-loc/filter_plugins/dict_to_cli_args.py
  * corresponding tests under tests/unit/*
- roles/svc-db-postgres/filter_plugins/split_postgres_connections.py: dropped no-longer-needed list_postgres_roles API; adjusted tests.

Misc:
- sys-stk-front-proxy/defaults/main.yml: clarified valid vhost_flavour values (comma-separated).

Ref: https://chatgpt.com/share/68b56bac-c4f8-800f-aeef-6708dbb44199
2025-09-01 11:47:51 +02:00
34b3f3b0ad Optimized healthcheck link for web-app-yourls 2025-09-01 10:54:08 +02:00
94fe58b5da safe_join: raise ValueError on None parameters and update tests
Changed safe_join to raise ValueError if base or tail is None instead of returning 'None/path'.
Adjusted unit tests accordingly to expect exceptions for None inputs and kept empty-string handling valid.

Ref: https://chatgpt.com/share/68b55850-e854-800f-9702-09ea956b8dc4
2025-09-01 10:25:08 +02:00
9feb766e6f replaced style-src-elem by style-src 2025-09-01 10:14:03 +02:00
231fd567b3 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
2025-09-01 10:10:23 +02:00
3f8e7c1733 Refactor CSP filter:
- Move default 'unsafe-inline' for style-src and style-src-elem into get_csp_flags
- Ensure hashes are only added if 'unsafe-inline' not in final tokens
- Improve comments and structure
- Extend unit tests to cover default flags, overrides, and final-token logic
See: https://chatgpt.com/share/68b54520-5cfc-800f-9bac-45093740df78
2025-09-01 09:03:22 +02:00
3bfab9ef8e feat(filter_plugins/url_join): add query parameter support
- Support query elements starting with '?' or '&'
  * First query element normalized to '?', subsequent to '&'
  * Each query element must be exactly one 'key=value' pair
  * Query elements may only appear after path elements
  * Once query starts, no more path elements are allowed
- Extend test suite with success and failure cases for query handling

See: https://chatgpt.com/share/68b537ea-d198-800f-927a-940c4de832f2
2025-09-01 08:16:22 +02:00
f1870c07be refactor(filter_plugins/url_join): enforce mandatory scheme and raise specific AnsibleFilterError messages
Improved url_join filter:
- Requires first element to contain a valid '<scheme>://'
- Raises specific errors for None, empty list, wrong type, missing scheme,
  extra schemes in later parts, or string conversion failures
- Provides clearer error messages with index context in parts

See: https://chatgpt.com/share/68b537ea-d198-800f-927a-940c4de832f2
2025-09-01 08:06:48 +02:00
176 changed files with 2919 additions and 2759 deletions

View File

@@ -228,7 +228,7 @@ def parse_meta_dependencies(role_dir: str) -> List[str]:
def sanitize_run_once_var(role_name: str) -> str:
"""
Generate run_once variable name from role name.
Example: 'sys-srv-web-inj-logout' -> 'run_once_sys_srv_web_inj_logout'
Example: 'sys-front-inj-logout' -> 'run_once_sys_front_inj_logout'
"""
return "run_once_" + role_name.replace("-", "_")

View File

@@ -1,86 +0,0 @@
from ansible.errors import AnsibleFilterError
class FilterModule(object):
def filters(self):
return {'alias_domains_map': self.alias_domains_map}
def alias_domains_map(self, apps, PRIMARY_DOMAIN):
"""
Build a map of application IDs to their alias domains.
- If no `domains` key → []
- If `domains` exists but is an empty dict → return the original cfg
- Explicit `aliases` are used (default appended if missing)
- If only `canonical` defined and it doesn't include default, default is added
- Invalid types raise AnsibleFilterError
"""
def parse_entry(domains_cfg, key, app_id):
if key not in domains_cfg:
return None
entry = domains_cfg[key]
if isinstance(entry, dict):
values = list(entry.values())
elif isinstance(entry, list):
values = entry
else:
raise AnsibleFilterError(
f"Unexpected type for 'domains.{key}' in application '{app_id}': {type(entry).__name__}"
)
for d in values:
if not isinstance(d, str) or not d.strip():
raise AnsibleFilterError(
f"Invalid domain entry in '{key}' for application '{app_id}': {d!r}"
)
return values
def default_domain(app_id, primary):
return f"{app_id}.{primary}"
# 1) Precompute canonical domains per app (fallback to default)
canonical_map = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('server',{}).get('domains',{})
entry = domains_cfg.get('canonical')
if entry is None:
canonical_map[app_id] = [default_domain(app_id, PRIMARY_DOMAIN)]
elif isinstance(entry, dict):
canonical_map[app_id] = list(entry.values())
elif isinstance(entry, list):
canonical_map[app_id] = list(entry)
else:
raise AnsibleFilterError(
f"Unexpected type for 'server.domains.canonical' in application '{app_id}': {type(entry).__name__}"
)
# 2) Build alias list per app
result = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('server',{}).get('domains')
# no domains key → no aliases
if domains_cfg is None:
result[app_id] = []
continue
# empty domains dict → return the original cfg
if isinstance(domains_cfg, dict) and not domains_cfg:
result[app_id] = cfg
continue
# otherwise, compute aliases
aliases = parse_entry(domains_cfg, 'aliases', app_id) or []
default = default_domain(app_id, PRIMARY_DOMAIN)
has_aliases = 'aliases' in domains_cfg
has_canon = 'canonical' in domains_cfg
if has_aliases:
if default not in aliases:
aliases.append(default)
elif has_canon:
canon = canonical_map.get(app_id, [])
if default not in canon and default not in aliases:
aliases.append(default)
result[app_id] = aliases
return result

View File

@@ -1,12 +1,15 @@
from ansible.errors import AnsibleFilterError
import hashlib
import base64
import sys, os
import sys
import os
# Ensure module_utils is importable when this filter runs from Ansible
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from module_utils.config_utils import get_app_conf
from module_utils.get_url import get_url
class FilterModule(object):
"""
Custom filters for Content Security Policy generation and CSP-related utilities.
@@ -17,10 +20,14 @@ class FilterModule(object):
'build_csp_header': self.build_csp_header,
}
# -------------------------------
# Helpers
# -------------------------------
@staticmethod
def is_feature_enabled(applications: dict, feature: str, application_id: str) -> bool:
"""
Return True if applications[application_id].features[feature] is truthy.
Returns True if applications[application_id].features[feature] is truthy.
"""
return get_app_conf(
applications,
@@ -32,6 +39,10 @@ class FilterModule(object):
@staticmethod
def get_csp_whitelist(applications, application_id, directive):
"""
Returns a list of additional whitelist entries for a given directive.
Accepts both scalar and list in config; always returns a list.
"""
wl = get_app_conf(
applications,
application_id,
@@ -48,28 +59,37 @@ class FilterModule(object):
@staticmethod
def get_csp_flags(applications, application_id, directive):
"""
Dynamically extract all CSP flags for a given directive and return them as tokens,
e.g., "'unsafe-eval'", "'unsafe-inline'", etc.
Returns CSP flag tokens (e.g., "'unsafe-eval'", "'unsafe-inline'") for a directive,
merging sane defaults with app config.
Default: 'unsafe-inline' is enabled for style-src and style-src-elem.
"""
flags = get_app_conf(
# Defaults that apply to all apps
default_flags = {}
if directive in ('style-src', 'style-src-elem'):
default_flags = {'unsafe-inline': True}
configured = get_app_conf(
applications,
application_id,
'server.csp.flags.' + directive,
False,
{}
)
tokens = []
for flag_name, enabled in flags.items():
# Merge defaults with configured flags (configured overrides defaults)
merged = {**default_flags, **configured}
tokens = []
for flag_name, enabled in merged.items():
if enabled:
tokens.append(f"'{flag_name}'")
return tokens
@staticmethod
def get_csp_inline_content(applications, application_id, directive):
"""
Return inline script/style snippets to hash for a given CSP directive.
Returns inline script/style snippets to hash for a given directive.
Accepts both scalar and list in config; always returns a list.
"""
snippets = get_app_conf(
applications,
@@ -87,7 +107,7 @@ class FilterModule(object):
@staticmethod
def get_csp_hash(content):
"""
Compute the SHA256 hash of the given inline content and return
Computes the SHA256 hash of the given inline content and returns
a CSP token like "'sha256-<base64>'".
"""
try:
@@ -97,6 +117,10 @@ class FilterModule(object):
except Exception as exc:
raise AnsibleFilterError(f"get_csp_hash failed: {exc}")
# -------------------------------
# Main builder
# -------------------------------
def build_csp_header(
self,
applications,
@@ -106,75 +130,80 @@ class FilterModule(object):
matomo_feature_name='matomo'
):
"""
Build the Content-Security-Policy header value dynamically based on application settings.
Inline hashes are read from applications[application_id].csp.hashes
Builds the Content-Security-Policy header value dynamically based on application settings.
- Flags (e.g., 'unsafe-eval', 'unsafe-inline') are read from server.csp.flags.<directive>,
with sane defaults applied in get_csp_flags (always 'unsafe-inline' for style-src and style-src-elem).
- Inline hashes are read from server.csp.hashes.<directive>.
- Whitelists are read from server.csp.whitelist.<directive>.
- Inline hashes are added only if the final tokens do NOT include 'unsafe-inline'.
"""
try:
directives = [
'default-src', # Fallback source list for all content types not explicitly listed
'connect-src', # Controls allowed URLs for XHR, WebSockets, EventSource, and fetch()
'frame-ancestors', # Restricts which parent frames can embed this page via <iframe>, <object>, <embed>, <applet>
'frame-src', # Controls allowed sources for nested browsing contexts like <iframe>
'script-src', # Controls allowed sources for inline scripts and <script> elements (general script execution)
'script-src-elem', # Controls allowed sources specifically for <script> elements (separate from inline/event handlers)
'style-src', # Controls allowed sources for inline styles and <style>/<link> elements (general styles)
'style-src-elem', # Controls allowed sources specifically for <style> and <link rel="stylesheet"> elements
'font-src', # Controls allowed sources for fonts loaded via @font-face
'worker-src', # Controls allowed sources for web workers, shared workers, and service workers
'manifest-src', # Controls allowed sources for web app manifests
'media-src', # Controls allowed sources for media files like <audio> and <video>
'default-src', # Fallback source list for content types not explicitly listed
'connect-src', # Allowed URLs for XHR, WebSockets, EventSource, fetch()
'frame-ancestors', # Who may embed this page
'frame-src', # Sources for nested browsing contexts (e.g., <iframe>)
'script-src', # Sources for script execution
'script-src-elem', # Sources for <script> elements
'style-src', # Sources for inline styles and <style>/<link> elements
'style-src-elem', # Sources for <style> and <link rel="stylesheet">
'font-src', # Sources for fonts
'worker-src', # Sources for workers
'manifest-src', # Sources for web app manifests
'media-src', # Sources for audio and video
]
parts = []
for directive in directives:
tokens = ["'self'"]
# unsafe-eval / unsafe-inline flags
# 1) Load flags (includes defaults from get_csp_flags)
flags = self.get_csp_flags(applications, application_id, directive)
tokens += flags
if directive in [ 'script-src-elem', 'connect-src', 'style-src-elem' ]:
# Allow fetching from internal CDN as default for all applications
tokens.append(get_url(domains,'web-svc-cdn',web_protocol))
if directive in ['script-src-elem', 'connect-src']:
# Matomo integration
if self.is_feature_enabled(applications, matomo_feature_name, application_id):
tokens.append(get_url(domains,'web-app-matomo',web_protocol))
# 2) Allow fetching from internal CDN by default for selected directives
if directive in ['script-src-elem', 'connect-src', 'style-src-elem']:
tokens.append(get_url(domains, 'web-svc-cdn', web_protocol))
# ReCaptcha integration: allow loading scripts from Google if feature enabled
# 3) Matomo integration if feature is enabled
if directive in ['script-src-elem', 'connect-src']:
if self.is_feature_enabled(applications, matomo_feature_name, application_id):
tokens.append(get_url(domains, 'web-app-matomo', web_protocol))
# 4) ReCaptcha integration (scripts + frames) if feature is enabled
if self.is_feature_enabled(applications, 'recaptcha', application_id):
if directive in ['script-src-elem',"frame-src"]:
if directive in ['script-src-elem', 'frame-src']:
tokens.append('https://www.gstatic.com')
tokens.append('https://www.google.com')
# 5) Frame ancestors handling (desktop + logout support)
if directive == 'frame-ancestors':
# Enable loading via ancestors
if self.is_feature_enabled(applications, 'desktop', application_id):
# Allow being embedded by the desktop app domain (and potentially its parent)
domain = domains.get('web-app-desktop')[0]
sld_tld = ".".join(domain.split(".")[-2:]) # yields "example.com"
tokens.append(f"{sld_tld}") # yields "*.example.com"
sld_tld = ".".join(domain.split(".")[-2:]) # e.g., example.com
tokens.append(f"{sld_tld}")
if self.is_feature_enabled(applications, 'logout', application_id):
# Allow logout via infinito logout proxy
tokens.append(get_url(domains,'web-svc-logout',web_protocol))
# Allow logout via keycloak app
tokens.append(get_url(domains,'web-app-keycloak',web_protocol))
# Allow embedding via logout proxy and Keycloak app
tokens.append(get_url(domains, 'web-svc-logout', web_protocol))
tokens.append(get_url(domains, 'web-app-keycloak', web_protocol))
# whitelist
# 6) Custom whitelist entries
tokens += self.get_csp_whitelist(applications, application_id, directive)
# only add hashes if 'unsafe-inline' is NOT in flags
if "'unsafe-inline'" not in flags:
# 7) Add inline content hashes ONLY if final tokens do NOT include 'unsafe-inline'
# (Check tokens, not flags, to include defaults and later modifications.)
if "'unsafe-inline'" not in tokens:
for snippet in self.get_csp_inline_content(applications, application_id, directive):
tokens.append(self.get_csp_hash(snippet))
# Append directive
parts.append(f"{directive} {' '.join(tokens)};")
# static img-src
# 8) Static img-src directive (kept permissive for data/blob and any host)
parts.append("img-src * data: blob:;")
return ' '.join(parts)
except Exception as exc:

View File

@@ -1,49 +0,0 @@
import os
import re
import yaml
from ansible.errors import AnsibleFilterError
def get_application_id(role_name):
"""
Jinja2/Ansible filter: given a role name, load its vars/main.yml and return the application_id value.
"""
# Construct path: assumes current working directory is project root
vars_file = os.path.join(os.getcwd(), 'roles', role_name, 'vars', 'main.yml')
if not os.path.isfile(vars_file):
raise AnsibleFilterError(f"Vars file not found for role '{role_name}': {vars_file}")
try:
# Read entire file content to avoid lazy stream issues
with open(vars_file, 'r', encoding='utf-8') as f:
content = f.read()
data = yaml.safe_load(content)
except Exception as e:
raise AnsibleFilterError(f"Error reading YAML from {vars_file}: {e}")
# Ensure parsed data is a mapping
if not isinstance(data, dict):
raise AnsibleFilterError(
f"Error reading YAML from {vars_file}: expected mapping, got {type(data).__name__}"
)
# Detect malformed YAML: no valid identifier-like keys
valid_key_pattern = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
if data and not any(valid_key_pattern.match(k) for k in data.keys()):
raise AnsibleFilterError(f"Error reading YAML from {vars_file}: invalid top-level keys")
if 'application_id' not in data:
raise AnsibleFilterError(f"Key 'application_id' not found in {vars_file}")
return data['application_id']
class FilterModule(object):
"""
Ansible filter plugin entry point.
"""
def filters(self):
return {
'get_application_id': get_application_id,
}

View File

@@ -1,122 +0,0 @@
import os
import yaml
import re
from ansible.errors import AnsibleFilterError
# in-memory cache: application_id → (parsed_yaml, is_nested)
_cfg_cache = {}
def load_configuration(application_id, key):
if not isinstance(key, str):
raise AnsibleFilterError("Key must be a dotted-string, e.g. 'features.matomo'")
# locate roles/
here = os.path.dirname(__file__)
root = os.path.abspath(os.path.join(here, '..'))
roles_dir = os.path.join(root, 'roles')
if not os.path.isdir(roles_dir):
raise AnsibleFilterError(f"Roles directory not found at {roles_dir}")
# first time? load & cache
if application_id not in _cfg_cache:
config_path = None
# 1) primary: vars/main.yml declares it
for role in os.listdir(roles_dir):
mv = os.path.join(roles_dir, role, 'vars', 'main.yml')
if os.path.exists(mv):
try:
md = yaml.safe_load(open(mv)) or {}
except Exception:
md = {}
if md.get('application_id') == application_id:
cf = os.path.join(roles_dir, role, "config" , "main.yml")
if not os.path.exists(cf):
raise AnsibleFilterError(
f"Role '{role}' declares '{application_id}' but missing config/main.yml"
)
config_path = cf
break
# 2) fallback nested
if config_path is None:
for role in os.listdir(roles_dir):
cf = os.path.join(roles_dir, role, "config" , "main.yml")
if not os.path.exists(cf):
continue
try:
dd = yaml.safe_load(open(cf)) or {}
except Exception:
dd = {}
if isinstance(dd, dict) and application_id in dd:
config_path = cf
break
# 3) fallback flat
if config_path is None:
for role in os.listdir(roles_dir):
cf = os.path.join(roles_dir, role, "config" , "main.yml")
if not os.path.exists(cf):
continue
try:
dd = yaml.safe_load(open(cf)) or {}
except Exception:
dd = {}
# flat style: dict with all non-dict values
if isinstance(dd, dict) and not any(isinstance(v, dict) for v in dd.values()):
config_path = cf
break
if config_path is None:
return None
# parse once
try:
parsed = yaml.safe_load(open(config_path)) or {}
except Exception as e:
raise AnsibleFilterError(f"Error loading config/main.yml at {config_path}: {e}")
# detect nested vs flat
is_nested = isinstance(parsed, dict) and (application_id in parsed)
_cfg_cache[application_id] = (parsed, is_nested)
parsed, is_nested = _cfg_cache[application_id]
# pick base entry
entry = parsed[application_id] if is_nested else parsed
# resolve dotted key
key_parts = key.split('.')
for part in key_parts:
# Check if part has an index (e.g., domains.canonical[0])
match = re.match(r'([^\[]+)\[([0-9]+)\]', part)
if match:
part, index = match.groups()
index = int(index)
if isinstance(entry, dict) and part in entry:
entry = entry[part]
# Check if entry is a list and access the index
if isinstance(entry, list) and 0 <= index < len(entry):
entry = entry[index]
else:
raise AnsibleFilterError(
f"Index '{index}' out of range for key '{part}' in application '{application_id}'"
)
else:
raise AnsibleFilterError(
f"Key '{part}' not found under application '{application_id}'"
)
else:
if isinstance(entry, dict) and part in entry:
entry = entry[part]
else:
raise AnsibleFilterError(
f"Key '{part}' not found under application '{application_id}'"
)
return entry
class FilterModule(object):
def filters(self):
return {'load_configuration': load_configuration}

View File

@@ -1,55 +0,0 @@
from jinja2 import Undefined
def safe_placeholders(template: str, mapping: dict = None) -> str:
"""
Format a template like "{url}/logo.png".
If mapping is provided (not None) and ANY placeholder is missing or maps to None/empty string, the function will raise KeyError.
If mapping is None, missing placeholders or invalid templates return empty string.
Numerical zero or False are considered valid values.
Any other formatting errors return an empty string.
"""
# Non-string templates yield empty
if not isinstance(template, str):
return ''
class SafeDict(dict):
def __getitem__(self, key):
val = super().get(key, None)
# Treat None or empty string as missing
if val is None or (isinstance(val, str) and val == ''):
raise KeyError(key)
return val
def __missing__(self, key):
raise KeyError(key)
silent = mapping is None
data = mapping or {}
try:
return template.format_map(SafeDict(data))
except KeyError:
if silent:
return ''
raise
except Exception:
return ''
def safe_var(value):
"""
Ansible filter: returns the value unchanged unless it's Undefined or None,
in which case returns an empty string.
Catches all exceptions and yields ''.
"""
try:
if isinstance(value, Undefined) or value is None:
return ''
return value
except Exception:
return ''
class FilterModule(object):
def filters(self):
return {
'safe_var': safe_var,
'safe_placeholders': safe_placeholders,
}

View File

@@ -1,28 +0,0 @@
"""
Ansible filter plugin that joins a base string and a tail path safely.
If the base is falsy (None, empty, etc.), returns an empty string.
"""
def safe_join(base, tail):
"""
Safely join base and tail into a path or URL.
- base: the base string. If falsy, returns ''.
- tail: the string to append. Leading/trailing slashes are handled.
- On any exception, returns ''.
"""
try:
if not base:
return ''
base_str = str(base).rstrip('/')
tail_str = str(tail).lstrip('/')
return f"{base_str}/{tail_str}"
except Exception:
return ''
class FilterModule(object):
def filters(self):
return {
'safe_join': safe_join,
}

146
filter_plugins/url_join.py Normal file
View File

@@ -0,0 +1,146 @@
"""
Ansible filter plugin that safely joins URL components from a list.
- Requires a valid '<scheme>://' in the first element (any RFC-3986-ish scheme)
- Preserves the double slash after the scheme, collapses other duplicate slashes
- Supports query parts introduced by elements starting with '?' or '&'
* first query element uses '?', subsequent use '&' (regardless of given prefix)
* each query element must be exactly one 'key=value' pair
* query elements may only appear after path elements; once query starts, no more path parts
- Raises specific AnsibleFilterError messages for common misuse
"""
import re
from ansible.errors import AnsibleFilterError
_SCHEME_RE = re.compile(r'^([a-zA-Z][a-zA-Z0-9+.\-]*://)(.*)$')
_QUERY_PAIR_RE = re.compile(r'^[^&=?#]+=[^&?#]*$') # key=value (no '&', no extra '?' or '#')
def _to_str_or_error(obj, index):
"""Cast to str, raising a specific AnsibleFilterError with index context."""
try:
return str(obj)
except Exception as e:
raise AnsibleFilterError(
f"url_join: unable to convert part at index {index} to string: {e}"
)
def url_join(parts):
"""
Join a list of URL parts, URL-aware (scheme, path, query).
Args:
parts (list|tuple): URL segments. First element MUST include '<scheme>://'.
Path elements are plain strings.
Query elements must start with '?' or '&' and contain exactly one 'key=value'.
Returns:
str: Joined URL.
Raises:
AnsibleFilterError: with specific, descriptive messages.
"""
# --- basic input validation ---
if parts is None:
raise AnsibleFilterError("url_join: parts must be a non-empty list; got None")
if not isinstance(parts, (list, tuple)):
raise AnsibleFilterError(
f"url_join: parts must be a list/tuple; got {type(parts).__name__}"
)
if len(parts) == 0:
raise AnsibleFilterError("url_join: parts must be a non-empty list")
# --- first element must carry a scheme ---
first_raw = parts[0]
if first_raw is None:
raise AnsibleFilterError(
"url_join: first element must include a scheme like 'https://'; got None"
)
first_str = _to_str_or_error(first_raw, 0)
m = _SCHEME_RE.match(first_str)
if not m:
raise AnsibleFilterError(
"url_join: first element must start with '<scheme>://', e.g. 'https://example.com'; "
f"got '{first_str}'"
)
scheme = m.group(1) # e.g., 'https://', 'ftp://', 'myapp+v1://'
after_scheme = m.group(2).lstrip('/') # strip only leading slashes right after scheme
# --- iterate parts: collect path parts until first query part; then only query parts allowed ---
path_parts = []
query_pairs = []
in_query = False
for i, p in enumerate(parts):
if p is None:
# skip None silently (consistent with path_join-ish behavior)
continue
s = _to_str_or_error(p, i)
# disallow additional scheme in later parts
if i > 0 and "://" in s:
raise AnsibleFilterError(
f"url_join: only the first element may contain a scheme; part at index {i} "
f"looks like a URL with scheme ('{s}')."
)
# first element: replace with remainder after scheme and continue
if i == 0:
s = after_scheme
# check if this is a query element (starts with ? or &)
if s.startswith('?') or s.startswith('&'):
in_query = True
raw_pair = s[1:] # strip the leading ? or &
if raw_pair == '':
raise AnsibleFilterError(
f"url_join: query element at index {i} is empty; expected '?key=value' or '&key=value'"
)
# Disallow multiple pairs in a single element; enforce exactly one key=value
if '&' in raw_pair:
raise AnsibleFilterError(
f"url_join: query element at index {i} must contain exactly one 'key=value' pair "
f"without '&'; got '{s}'"
)
if not _QUERY_PAIR_RE.match(raw_pair):
raise AnsibleFilterError(
f"url_join: query element at index {i} must match 'key=value' (no extra '?', '&', '#'); got '{s}'"
)
query_pairs.append(raw_pair)
else:
# non-query element
if in_query:
# once query started, no more path parts allowed
raise AnsibleFilterError(
f"url_join: path element found at index {i} after query parameters started; "
f"query parts must come last"
)
# normal path part: strip slashes to avoid duplicate '/'
path_parts.append(s.strip('/'))
# normalize path: remove empty chunks
path_parts = [p for p in path_parts if p != '']
# --- build result ---
# path portion
if path_parts:
joined_path = "/".join(path_parts)
base = scheme + joined_path
else:
# no path beyond scheme
base = scheme
# query portion
if query_pairs:
base = base + "?" + "&".join(query_pairs)
return base
class FilterModule(object):
def filters(self):
return {
'url_join': url_join,
}

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from ansible.plugins.lookup import LookupBase
from ansible.errors import AnsibleError
import os
class LookupModule(LookupBase):
"""
Return a cache-busting string based on the LOCAL file's mtime.
Usage (single path → string via Jinja):
{{ lookup('local_mtime_qs', '/path/to/file.css') }}
-> "?version=1712323456"
Options:
param (str): query parameter name (default: "version")
mode (str): "qs" (default) → returns "?<param>=<mtime>"
"epoch" → returns "<mtime>"
Multiple paths (returns list, one result per term):
{{ lookup('local_mtime_qs', '/a.js', '/b.js', param='v') }}
"""
def run(self, terms, variables=None, **kwargs):
if not terms:
return []
param = kwargs.get('param', 'version')
mode = kwargs.get('mode', 'qs')
if mode not in ('qs', 'epoch'):
raise AnsibleError("local_mtime_qs: 'mode' must be 'qs' or 'epoch'")
results = []
for term in terms:
path = os.path.abspath(os.path.expanduser(str(term)))
# Fail fast if path is missing or not a regular file
if not os.path.exists(path):
raise AnsibleError(f"local_mtime_qs: file does not exist: {path}")
if not os.path.isfile(path):
raise AnsibleError(f"local_mtime_qs: not a regular file: {path}")
try:
mtime = int(os.stat(path).st_mtime)
except OSError as e:
raise AnsibleError(f"local_mtime_qs: cannot stat '{path}': {e}")
if mode == 'qs':
results.append(f"?{param}={mtime}")
else: # mode == 'epoch'
results.append(str(mtime))
return results

View File

@@ -56,6 +56,16 @@ roles:
description: "Stack levels to setup the server"
icon: "fas fa-bars-staggered"
invokable: false
front:
title: "System Frontend Helpers"
description: "Frontend helpers for reverse-proxied apps (injection, shared assets, CDN plumbing)."
icon: "fas fa-wand-magic-sparkles"
invokable: false
inj:
title: "Injection"
description: "Composable HTML injection roles (CSS, JS, logout interceptor, analytics, desktop iframe) for Nginx/OpenResty via sub_filter/Lua with CDN-backed assets."
icon: "fas fa-filter"
invokable: false
update:
title: "Updates & Package Management"
description: "OS & package updates"
@@ -106,11 +116,6 @@ roles:
description: "General server roles for provisioning and managing server infrastructure—covering web servers, proxy servers, network services, and other backend components."
icon: "fas fa-server"
invokable: false
web:
title: "Webserver"
description: "Web-server roles for installing and configuring Nginx (core, TLS, injection filters, composer modules)."
icon: "fas fa-server"
invokable: false
proxy:
title: "Proxy Server"
description: "Proxy-server roles for virtual-host orchestration and reverse-proxy setups."

View File

@@ -19,3 +19,5 @@
template:
src: caffeine.desktop.j2
dest: "{{auto_start_directory}}caffeine.desktop"
- include_tasks: utils/run_once.yml

View File

@@ -1,4 +1,3 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: utils/run_once.yml
when: run_once_desk_gnome_caffeine is not defined

View File

@@ -48,4 +48,6 @@
state: present
create: yes
mode: "0644"
become: false
become: false
- include_tasks: utils/run_once.yml

View File

@@ -1,4 +1,3 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: utils/run_once.yml
when: run_once_desk_ssh is not defined

View File

@@ -1,8 +1,14 @@
---
- name: Setup locale.gen
template: src=locale.gen dest=/etc/locale.gen
template:
src: locale.gen.j2
dest: /etc/locale.gen
- name: Setup locale.conf
template: src=locale.conf dest=/etc/locale.conf
template:
src: locale.conf.j2
dest: /etc/locale.conf
- name: Generate locales
shell: locale-gen
become: true

View File

@@ -1,2 +0,0 @@
LANG=en_US.UTF-8
LANGUAGE=en_US.UTF-8

View File

@@ -0,0 +1,2 @@
LANG={{ HOST_LL_CC }}.UTF-8
LANGUAGE={{ HOST_LL_CC }}.UTF-8

View File

@@ -3,6 +3,10 @@
- "CMD"
- "curl"
- "-f"
{% if container_hostname %}
- "-H"
- "Host: {{ container_hostname }}"
{% endif %}
- "http://127.0.0.1{{ (":" ~ container_port) if container_port is defined else '' }}/{{ container_healthcheck | default('') }}"
interval: 1m
timeout: 10s

View File

@@ -2,7 +2,7 @@
This Ansible role composes and orchestrates all necessary HTTPS-layer tasks and HTML-content injections for your webserver domains. It integrates two key sub-roles into a unified workflow:
1. **`sys-srv-web-inj-compose`**
1. **`sys-front-inj-all`**
Injects global HTML snippets (CSS, Matomo tracking, iFrame notifier, custom JavaScript) into responses using Nginx `sub_filter`.
2. **`sys-svc-certs`**
Handles issuing, renewing, and managing TLS certificates via ACME/Certbot.

View File

@@ -1,8 +1,8 @@
# run_once_srv_composer: deactivated
- name: "include role sys-srv-web-inj-compose for '{{ domain }}'"
- name: "include role sys-front-inj-all for '{{ domain }}'"
include_role:
name: sys-srv-web-inj-compose
name: sys-front-inj-all
- name: "include role sys-svc-certs for '{{ domain }}'"
include_role:

View File

@@ -35,6 +35,6 @@ location {{location}}
{% if proxy_lua_enabled %}
proxy_set_header Accept-Encoding "";
{% include 'roles/sys-srv-web-inj-compose/templates/location.lua.j2'%}
{% include 'roles/sys-front-inj-all/templates/location.lua.j2'%}
{% endif %}
}

View File

@@ -7,7 +7,7 @@ server
{% include 'roles/web-app-oauth2-proxy/templates/endpoint.conf.j2'%}
{% endif %}
{% include 'roles/sys-srv-web-inj-compose/templates/server.conf.j2'%}
{% include 'roles/sys-front-inj-all/templates/server.conf.j2'%}
{% if proxy_extra_configuration is defined %}
{# Additional Domain Specific Configuration #}

View File

@@ -8,7 +8,7 @@ server {
{% include 'roles/srv-letsencrypt/templates/ssl_header.j2' %}
{% include 'roles/sys-srv-web-inj-compose/templates/server.conf.j2' %}
{% include 'roles/sys-front-inj-all/templates/server.conf.j2' %}
client_max_body_size {{ client_max_body_size | default('100m') }};
keepalive_timeout 70;

View File

@@ -1,77 +0,0 @@
def build_ldap_nested_group_entries(applications, users, ldap):
"""
Builds structured LDAP role entries using the global `ldap` configuration.
Supports objectClasses: posixGroup (adds gidNumber, memberUid), groupOfNames (adds member).
Now nests roles under an application-level OU: application-id/role.
"""
result = {}
# Base DN components
role_dn_base = ldap["DN"]["OU"]["ROLES"]
user_dn_base = ldap["DN"]["OU"]["USERS"]
ldap_user_attr = ldap["USER"]["ATTRIBUTES"]["ID"]
# Supported objectClass flavors
flavors = ldap.get("RBAC").get("FLAVORS")
for application_id, app_config in applications.items():
# Compute the DN for the application-level OU
app_ou_dn = f"ou={application_id},{role_dn_base}"
ou_entry = {
"dn": app_ou_dn,
"objectClass": ["top", "organizationalUnit"],
"ou": application_id,
"description": f"Roles for application {application_id}"
}
result[app_ou_dn] = ou_entry
# Standard roles with an extra 'administrator'
base_roles = app_config.get("rbac", {}).get("roles", {})
roles = {
**base_roles,
"administrator": {
"description": "Has full administrative access: manage themes, plugins, settings, and users"
}
}
group_id = app_config.get("group_id")
for role_name, role_conf in roles.items():
# Build CN under the application OU
cn = role_name
dn = f"cn={cn},{app_ou_dn}"
entry = {
"dn": dn,
"cn": cn,
"description": role_conf.get("description", ""),
"objectClass": ["top"] + flavors,
}
member_dns = []
member_uids = []
for username, user_conf in users.items():
if role_name in user_conf.get("roles", []):
member_dns.append(f"{ldap_user_attr}={username},{user_dn_base}")
member_uids.append(username)
if "posixGroup" in flavors:
entry["gidNumber"] = group_id
if member_uids:
entry["memberUid"] = member_uids
if "groupOfNames" in flavors and member_dns:
entry["member"] = member_dns
result[dn] = entry
return result
class FilterModule(object):
def filters(self):
return {
"build_ldap_nested_group_entries": build_ldap_nested_group_entries
}

View File

@@ -37,22 +37,8 @@ def split_postgres_connections(total_connections, roles_dir="roles"):
denom = max(count, 1)
return max(1, total // denom)
def list_postgres_roles(roles_dir="roles"):
"""
Helper: return a list of role names that declare database_type: postgres in vars/main.yml.
"""
names = []
if not os.path.isdir(roles_dir):
return names
for name in os.listdir(roles_dir):
vars_main = os.path.join(roles_dir, name, "vars", "main.yml")
if os.path.isfile(vars_main) and _is_postgres_role(vars_main):
names.append(name)
return names
class FilterModule(object):
def filters(self):
return {
"split_postgres_connections": split_postgres_connections,
"list_postgres_roles": list_postgres_roles,
"split_postgres_connections": split_postgres_connections
}

View File

@@ -1,36 +0,0 @@
def dict_to_cli_args(data):
"""
Convert a dictionary into CLI argument string.
Example:
{
"backup-dir": "/mnt/backups",
"shutdown": True,
"ignore-volumes": ["redis", "memcached"]
}
becomes:
--backup-dir=/mnt/backups --shutdown --ignore-volumes="redis memcached"
"""
if not isinstance(data, dict):
raise TypeError("Expected a dictionary for CLI argument conversion")
args = []
for key, value in data.items():
cli_key = f"--{key}"
if isinstance(value, bool):
if value:
args.append(cli_key)
elif isinstance(value, list):
items = " ".join(map(str, value))
args.append(f'{cli_key}="{items}"')
elif value is not None:
args.append(f'{cli_key}={value}')
return " ".join(args)
class FilterModule(object):
def filters(self):
return {
'dict_to_cli_args': dict_to_cli_args
}

View File

@@ -1,4 +1,3 @@
# roles/sys-srv-web-inj-compose/filter_plugins/inj_enabled.py
#
# Usage in tasks:
# - set_fact:

View File

@@ -2,10 +2,10 @@
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-srv-web-inj-<feature>/templates/head_sub.j2
- kind='body' -> roles/sys-srv-web-inj-<feature>/templates/body_sub.j2
- 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-srv-web-inj-<feature>) does not
If the feature's role directory (roles/sys-front-inj-<feature>) does not
exist, this filter raises FileNotFoundError.
Usage in a template:
@@ -15,13 +15,13 @@ Usage in a template:
import os
# This file lives at: roles/sys-srv-web-inj-compose/filter_plugins/inj_snippets.py
# 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-srv-web-inj-compose
_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-srv-web-inj-{feature}")
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"):

View File

@@ -14,7 +14,7 @@ galaxy_info:
- 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-srv-web-inj-compose"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-all"
min_ansible_version: "2.9"
platforms:
- name: Any

View File

@@ -2,39 +2,10 @@
set_fact:
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
- 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_srv_web_inj_compose is not defined
- name: "Activate Portfolio iFrame notifier for '{{ domain }}'"
- name: "Load CDN Service for '{{ domain }}'"
include_role:
name: sys-srv-web-inj-desktop
public: true # Vars used in templates
when: inj_enabled.desktop
- name: "Load CDN for '{{ domain }}'"
include_role:
name: web-svc-cdn
public: false
when:
- inj_enabled.logout
- inj_enabled.desktop
- application_id != 'web-svc-cdn'
- run_once_web_svc_cdn is not defined
- name: Overwritte CDN handlers with neutral handlers
ansible.builtin.include_tasks: "{{ playbook_dir }}/tasks/utils/load_handlers.yml"
loop:
- svc-prx-openresty
- docker-compose
loop_control:
label: "{{ item }}"
vars:
handler_role_name: "{{ item }}"
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:
@@ -42,25 +13,37 @@
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-srv-web-inj-css
when:
- inj_enabled.css
- run_once_sys_srv_web_inj_css is not defined
name: sys-front-inj-css
when: inj_enabled.css
- name: "Activate Matomo Tracking for '{{ domain }}'"
include_role:
name: sys-srv-web-inj-matomo
name: sys-front-inj-matomo
when: inj_enabled.matomo
- name: "Activate Javascript for '{{ domain }}'"
include_role:
name: sys-srv-web-inj-javascript
name: sys-front-inj-javascript
when: inj_enabled.javascript
- name: "Activate logout proxy for '{{ domain }}'"
include_role:
name: sys-srv-web-inj-logout
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

@@ -3,7 +3,7 @@
{% set kind = list_name | regex_replace('_snippets$','') %}
{% for f in features if inj_enabled.get(f) -%}
{{ list_name }}[#{{ list_name }} + 1] = [=[
{%- include 'roles/sys-srv-web-inj-' ~ f ~ '/templates/' ~ kind ~ '_sub.j2' -%}
{%- include 'roles/sys-front-inj-' ~ f ~ '/templates/' ~ kind ~ '_sub.j2' -%}
]=]
{% endfor -%}
{%- endmacro %}

View File

@@ -1,7 +1,3 @@
{% if inj_enabled.css %}
{% include 'roles/sys-srv-web-inj-css/templates/location.conf.j2' %}
{% endif %}
{% if inj_enabled.logout %}
{% include 'roles/web-svc-logout/templates/logout-proxy.conf.j2' %}
{% endif %}

View File

@@ -2,12 +2,12 @@
## Description
This Ansible role ensures **consistent global theming** across all Nginx-served applications by injecting a unified `global.css` file.
This Ansible role ensures **consistent global theming** across all Nginx-served applications by injecting CSS files.
The role leverages [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/) to generate a dynamic, customizable color palette for light and dark mode, compatible with popular web tools like **Bootstrap**, **Keycloak**, **Nextcloud**, **Taiga**, **Mastodon**, and many more.
## Overview
This role deploys a centralized global stylesheet (`global.css`) that overrides the default theming of web applications served via Nginx. It's optimized to run only once per deployment and generates a **cache-busting version number** based on file modification timestamps.
This role deploys a centralized global stylesheet that overrides the default theming of web applications served via Nginx. It's optimized to run only once per deployment and generates a **cache-busting version number** based on file modification timestamps.
It includes support for **dark mode**, **custom fonts**, and **extensive Bootstrap and UI component overrides**.
## Purpose
@@ -18,7 +18,7 @@ It makes all applications feel like part of the same ecosystem — visually and
## Features
- 🎨 **Dynamic Theming** via [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/)
- 📁 **Unified global.css** deployment for all Nginx applications
- 📁 **Unified CSS Base Configuration** deployment for all Nginx applications
- 🌒 **Dark mode support** out of the box
- 🚫 **No duplication** tasks run once per deployment
- ⏱️ **Versioning logic** to bust browser cache

View File

@@ -0,0 +1,21 @@
- name: Include dependency 'srv-core'
include_role:
name: srv-core
when: run_once_srv_core is not defined
- name: Generate color palette with colorscheme-generator
set_fact:
color_palette: "{{ lookup('colorscheme', CSS_BASE_COLOR, count=CSS_COUNT, shades=CSS_SHADES) }}"
- name: Generate inverted color palette with colorscheme-generator
set_fact:
inverted_color_palette: "{{ lookup('colorscheme', CSS_BASE_COLOR, count=CSS_COUNT, shades=CSS_SHADES, invert_lightness=True) }}"
- name: Deploy default CSS files
template:
src: "{{ ['css', item ~ '.j2'] | path_join }}"
dest: "{{ [cdn_paths_all.shared.css, item] | path_join }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'
loop: "{{ CSS_FILES }}"

View File

@@ -0,0 +1,25 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: utils/run_once.yml
when: run_once_sys_front_inj_css is not defined
- name: "Resolve optional app style.css source for '{{ application_id }}'"
vars:
app_role_dir: "{{ playbook_dir }}/roles/{{ application_id }}"
_app_style_src: >-
{{ lookup('first_found', {
'files': ['templates/style.css.j2','files/style.css'],
'paths': [app_role_dir]
}, errors='ignore') | default('', true) }}
set_fact:
app_style_src: "{{ _app_style_src }}"
app_style_present: "{{ _app_style_src | length > 0 }}"
- name: "Deploy per-app '{{ app_style_src }}' to '{{ css_app_dst }}'"
when: app_style_present
copy:
content: "{{ lookup('template', app_style_src) }}"
dest: "{{ css_app_dst }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'

View File

@@ -0,0 +1,69 @@
/* Buttons (Background, Text, Border, and Shadow)
Now using a button background that is only slightly darker than the overall background */
html[native-dark-active] .btn, .btn {
background-color: var(--color-01-87);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-91), var(--color-01-95), var(--color-01-95));
color: var(--color-01-50);
border-color: var(--color-01-80);
cursor: pointer;
}
/* Navigation (Background and Text Colors) */
.navbar, .navbar-light, .navbar-dark, .navbar.bg-light {
background-color: var(--color-01-90);
/* New Gradient based on original background (90 -5, 90, 90 +1, 90 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-85), var(--color-01-90), var(--color-01-91), var(--color-01-95));
color: var(--color-01-50);
border-color: var(--color-01-85);
}
.navbar a {
color: var(--color-01-40);
}
.navbar a.dropdown-item {
color: var(--color-01-43);
}
/* Cards / Containers (Background, Border, and Shadow)
Cards now use a slightly lighter background and a bold, clear shadow */
.card {
background-color: var(--color-01-90);
/* New Gradient based on original background (90 -5, 90, 90 +1, 90 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-85), var(--color-01-90), var(--color-01-91), var(--color-01-95));
border-color: var(--color-01-85);
color: var(--color-01-12);
}
.card-body {
color: var(--color-01-40);
}
/* Dropdown Menu and Submenu (Background, Text, and Shadow) */
.navbar .dropdown-menu,
.nav-item .dropdown-menu {
background-color: var(--color-01-80);
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
color: var(--color-01-40);
}
.navbar-nav {
--bs-nav-link-hover-color: var(--color-01-17);
}
.dropdown-item {
color: var(--color-01-40);
background-color: var(--color-01-80);
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: var(--color-01-65);
/* New Gradient based on original background (65 -5, 65, 65 +1, 65 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-60), var(--color-01-65), var(--color-01-66), var(--color-01-70));
color: var(--color-01-40);
}

View File

@@ -0,0 +1,297 @@
/***
Global Theming Styles Color and Shadow Variables
HINT:
- Better overwritte CSS variables instead of individual elements.
- Don't use !important. If possible use a specific selector.
*/
{% if design.font.import_url %}
@import url('{{ design.font.import_url }}');
{% endif %}
/* Auto-generated by colorscheme-generator */
:root {
{% for var_name, color in color_palette.items() %}
{{ var_name }}: {{ color }};
{% endfor %}
}
@media (prefers-color-scheme: dark) {
:root {
{% for var_name, color in inverted_color_palette.items() %}
{{ var_name }}: {{ color }};
{% endfor %}
}
}
:root, ::after, ::before, ::backdrop {
/* For Dark Mode Plugin
* @See https://chromewebstore.google.com/detail/dark-mode/dmghijelimhndkbmpgbldicpogfkceaj
*/
--native-dark-accent-color: var(--color-01-60); /* was #a9a9a9 */
--native-dark-bg-color: var(--color-01-10); /* was #292929 */
--native-dark-bg-image-color: rgba(var(--color-01-rgb-01), 0.10); /* remains the same, or adjust if needed */
--native-dark-border-color: var(--color-01-40); /* was #555555 */
--native-dark-box-shadow: 0 0 0 1px rgb(var(--color-01-rgb-99), / 10%);
--native-dark-cite-color: var(--color-01-70); /* was #92de92 you might adjust if a green tone is needed */
--native-dark-fill-color: var(--color-01-50); /* was #7d7d7d */
--native-dark-font-color: var(--color-01-95); /* was #dcdcdc */
--native-dark-link-color: var(--color-01-80); /* was #8db2e5 */
--native-dark-visited-link-color: var(--color-01-85); /* was #c76ed7 */
}
/* Bootstrap Overrides (Color/Shadow Variables Only) */
:root {
--bs-black: var(--color-01-01); /* Original tone: Black (#000) */
--bs-white: var(--color-01-99); /* Original tone: White (#fff) */
--bs-gray: var(--color-01-50); /* Original tone: Gray (#6c757d) */
--bs-gray-dark: var(--color-01-20); /* Original tone: Dark Gray (#343a40) */
{% for i in range(1, 10) %}
{# @see https://chatgpt.com/share/67bcd94e-bb44-800f-bf63-06d1ae0f5096 #}
{% set gray = i * 100 %}
{% set color = 100 - i * 10 %}
--bs-gray-{{ gray }}: var(--color-01-{{ "%02d" % color }});
{% endfor %}
--bs-primary: var(--color-01-65); /* Original tone: Blue (#0d6efd) */
--bs-light: var(--color-01-95); /* Original tone: Light (#f8f9fa) */
--bs-dark: var(--color-01-10); /* Original tone: Dark (#212529) */
--bs-primary-rgb: var(--color-01-rgb-65); /* Original tone: Blue (13, 110, 253) */
--bs-secondary-rgb: var(--color-01-rgb-50); /* Original tone: Grayish (#6c757d / 108, 117, 125) */
--bs-light-rgb: var(--color-01-rgb-95); /* Original tone: Light (248, 249, 250) */
--bs-dark-rgb: var(--color-01-rgb-10); /* Original tone: Dark (33, 37, 41) */
--bs-white-rgb: var(--color-01-rgb-99); /* Original tone: White (255, 255, 255) */
--bs-black-rgb: var(--color-01-rgb-01); /* Original tone: Black (0, 0, 0) */
--bs-body-color-rgb: var(--color-01-rgb-10); /* Original tone: Dark (#212529 / 33, 37, 41) */
--bs-body-bg-rgb: var(--color-01-rgb-99); /* Original tone: White (#fff / 255, 255, 255) */
--bs-body-color: var(--color-01-10); /* Original tone: Dark (#212529) */
--bs-body-bg: var(--color-01-99); /* Original tone: White (#fff) */
--bs-border-color: var(--color-01-85); /* Original tone: Gray (#dee2e6) */
--bs-link-color: var(--color-01-65); /* Original tone: Blue (#0d6efd) */
--bs-link-hover-color: var(--color-01-60); /* Original tone: Darker Blue (#0a58ca) */
--bs-code-color: var(--color-01-55); /* Original tone: Pink (#d63384) */
--bs-highlight-bg: var(--color-01-93); /* Original tone: Light Yellow (#fff3cd) */
--bs-list-group-bg: var(--color-01-40);
--bs-emphasis-color: var(--color-01-01); /* Gemappt von #000 */
--bs-emphasis-color-rgb: var(--color-01-rgb-01); /* Gemappt von 0, 0, 0 */
--bs-secondary-color: rgba(var(--color-01-rgb-10), 0.75); /* Gemappt von rgba(33, 37, 41, 0.75) */
--bs-secondary-color-rgb: var(--color-01-rgb-10); /* Gemappt von 33, 37, 41 */
--bs-secondary-bg: var(--color-01-90); /* Gemappt von #e9ecef */
--bs-secondary-bg-rgb: var(--color-01-rgb-90); /* Gemappt von 233, 236, 239 */
--bs-tertiary-color: rgba(var(--color-01-rgb-10), 0.5); /* Gemappt von rgba(33, 37, 41, 0.5) */
--bs-tertiary-color-rgb: var(--color-01-rgb-10); /* Gemappt von 33, 37, 41 */
--bs-tertiary-bg: var(--color-01-95); /* Gemappt von #f8f9fa */
--bs-tertiary-bg-rgb: var(--color-01-rgb-95); /* Gemappt von 248, 249, 250 */
--bs-link-color-rgb: var(--color-01-rgb-65); /* Gemappt von 13, 110, 253 */
--bs-link-hover-color-rgb: var(--color-01-rgb-60); /* Gemappt von 10, 88, 202 */
--bs-highlight-color: var(--color-01-10); /* Gemappt von #212529 */
--bs-border-color-translucent: rgba(var(--color-01-rgb-01), 0.175); /* Gemappt von rgba(0, 0, 0, 0.175) */
--bs-focus-ring-color: rgba(var(--color-01-rgb-65), 0.25); /* Gemappt von rgba(13, 110, 253, 0.25) */
--bs-table-color: var(--bs-emphasis-color);
--bs-table-bg: var(--color-01-99); /* White (#fff) */
--bs-table-border-color: var(--color-01-99); /* White (#fff) */
--bs-table-striped-bg: var(--color-01-85); /* Light Gray (entspricht ca. #dee2e6) */
--bs-table-hover-color: var(--color-01-01); /* Black (#000) */
--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);
}
/* Global Defaults (Colors Only) */
body, html[native-dark-active] {
background-color: var(--color-01-93);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-93), var(--color-01-91), var(--color-01-95), var(--color-01-93));
background-attachment: fixed;
color: var(--color-01-40);
font-family: {{design.font.type}};
}
{# All links (applies to all anchor elements regardless of state) #}
a {
color: var(--color-01-50);
}
{# Unvisited links (applies only to links that have not been visited) #}
a:link {
color: var(--color-01-55);
}
{# Visited links (applies only to links that have been visited) #}
a:visited {
color: var(--color-01-45);
}
{# Hover state (applies when the mouse pointer is over the link) #}
a:hover {
color: var(--color-01-60);
}
{# Active state (applies during the time the link is being activated, e.g., on click) #}
a:active {
color: var(--color-01-65);
}
/** Set default buttons transparent **/
html[native-dark-active] button, button{
background-color: var(--color-01-87);
}
button:hover, .btn:hover {
filter: brightness(0.9);
}
/* {# Invalid state: when the input value fails validation criteria. Use danger color for error indication. #} */
input:invalid,
textarea:invalid,
select:invalid {
background-color: var(--color-01-01);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-01), var(--color-01-10));
/* Use Bootstrap danger color for error messages */
color: var(--bs-danger);
border-color: var(--color-01-20);
}
/* {# Valid state: when the input value meets all validation criteria. Use success color for confirmation. #} */
input:valid,
textarea:valid,
select:valid {
background-color: var(--color-01-80);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-80), var(--color-01-90));
/* Use Bootstrap success color for confirmation messages */
color: var(--bs-success);
border-color: var(--color-01-70);
}
/* {# Required field: applied to elements that must be filled out by the user. Use warning color for emphasis. #} */
input:required,
textarea:required,
select:required {
background-color: var(--color-01-50);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-50), var(--color-01-60));
/* Use Bootstrap warning color to indicate a required field */
color: var(--bs-warning);
border-color: var(--color-01-70);
}
/* {# Optional field: applied to elements that are not mandatory. Use info color to denote additional information. #} */
input:optional,
textarea:optional,
select:optional {
background-color: var(--color-01-60);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-60), var(--color-01-70));
/* Use Bootstrap info color to indicate optional information */
color: var(--bs-info);
border-color: var(--color-01-70);
}
/* {# Read-only state: when an element is not editable by the user. #} */
input:read-only,
textarea:read-only,
select:read-only {
background-color: var(--color-01-80);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-90), var(--color-01-70));
color: var(--color-01-20);
border-color: var(--color-01-50);
}
/* {# Read-write state: when an element is editable by the user. #} */
input:read-write,
textarea:read-write,
select:read-write {
background-color: var(--color-01-70);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-80));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
/* {# In-range: for inputs with a defined range, when the value is within the allowed limits. #} */
input:in-range,
textarea:in-range,
select:in-range {
background-color: var(--color-01-70);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-85));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
/* {# Out-of-range: for inputs with a defined range, when the value falls outside the allowed limits. #} */
input:out-of-range,
textarea:out-of-range,
select:out-of-range {
background-color: var(--color-01-10);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-10), var(--color-01-30));
color: var(--color-01-10);
border-color: var(--color-01-50);
}
/* {# Placeholder-shown: when the input field is displaying its placeholder text. #} */
input:placeholder-shown,
textarea:placeholder-shown,
select:placeholder-shown {
background-color: var(--color-01-82);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-82), var(--color-01-90));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
/* {# Focus state: when the element is focused by the user. #} */
input:focus,
textarea:focus,
select:focus {
background-color: var(--color-01-75);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-85));
color: var(--color-01-40);
border-color: var(--color-01-50);
}
/* {# Hover state: when the mouse pointer is over the element. #} */
input:hover,
textarea:hover,
select:hover {
background-color: var(--color-01-78);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-78), var(--color-01-88));
color: var(--color-01-40);
border-color: var(--color-01-65);
}
/* {# Active state: when the element is being activated (e.g., clicked). #} */
input:active,
textarea:active,
select:active {
background-color: var(--color-01-68);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-68), var(--color-01-78));
color: var(--color-01-40);
border-color: var(--color-01-60);
}
/* {# Checked state: specifically for radio buttons and checkboxes when selected. #} */
input:checked {
background-color: var(--color-01-90);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-90), var(--color-01-99));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
option {
background-color: var(--color-01-82);
color: var(--color-01-07);
}
/* Tables (Borders and Header Colors) */
th, td {
border-color: var(--color-01-70);
}
thead {
background-color: var(--color-01-80);
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
color: var(--color-01-40);
}
/* Headings (Text Color) */
h1, h2, h3, h4, h5, h6, p{
color: var(--color-01-10);
}

View File

@@ -0,0 +1,8 @@
{% set __css_tpl_dir = [playbook_dir, 'roles', 'sys-front-inj-css', 'templates', 'css'] | path_join %}
{% for css_file in ['default.css','bootstrap.css'] %}
<link rel="stylesheet" href="{{ [ cdn_urls.shared.css, css_file, lookup('local_mtime_qs', [__css_tpl_dir, css_file ~ '.j2'] | path_join)] | url_join }}">
{% endfor %}
{% if app_style_present | bool %}
<link rel="stylesheet" href="{{ [ cdn_urls.role.release.css, 'style.css', lookup('local_mtime_qs', app_style_src)] | url_join }}">
{% endif %}

View File

@@ -0,0 +1,8 @@
# Constants
CSS_FILES: ['default.css','bootstrap.css']
CSS_BASE_COLOR: "{{ design.css.colors.base }}"
CSS_COUNT: 7
CSS_SHADES: 100
# Variables
css_app_dst: "{{ [cdn_paths_all.role.release.css, 'style.css'] | path_join }}"

View File

@@ -0,0 +1,7 @@
- name: Deploy {{ INJ_DESKTOP_JS_FILE_NAME }}
template:
src: "{{ INJ_DESKTOP_JS_FILE_NAME }}.j2"
dest: "{{ INJ_DESKTOP_JS_FILE_DESTINATION }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'

View File

@@ -5,7 +5,7 @@
when: run_once_srv_core is not defined
- include_tasks: 01_deploy.yml
- include_tasks: utils/run_once.yml
when: run_once_sys_srv_web_inj_desktop is not defined
when: run_once_sys_front_inj_desktop is not defined
# --- Build tiny inline initializer (CSP-hashed) ---
- name: "Load iFrame init code for '{{ application_id }}'"

View File

@@ -0,0 +1 @@
<script src="{{ cdn_urls.shared.js }}/{{ INJ_DESKTOP_JS_FILE_NAME }}{{ lookup('local_mtime_qs', [playbook_dir, 'roles', 'sys-front-inj-desktop', 'templates', INJ_DESKTOP_JS_FILE_NAME ~ '.j2'] | path_join) }}"></script>

View File

@@ -0,0 +1,2 @@
INJ_DESKTOP_JS_FILE_NAME: "iframe-handler.js"
INJ_DESKTOP_JS_FILE_DESTINATION: "{{ [cdn_paths_all.shared.js, INJ_DESKTOP_JS_FILE_NAME] | path_join }}"

View File

@@ -5,7 +5,7 @@
name: srv-core
when: run_once_srv_core is not defined
- include_tasks: utils/run_once.yml
when: run_once_sys_srv_web_inj_javascript is not defined
when: run_once_sys_front_inj_javascript is not defined
- name: "Load JavaScript code for '{{ application_id }}'"
set_fact:

View File

@@ -1,10 +1,10 @@
# sys-srv-web-inj-logout
# sys-front-inj-logout
This role injects a catcher that intercepts all logout elements in HTML pages served by Nginx and redirects them to a centralized logout endpoint via JavaScript.
## Description
The `sys-srv-web-inj-logout` Ansible role automatically embeds a lightweight JavaScript snippet into your web application's HTML responses. This script identifies logout links, buttons, forms, and other elements, overrides their target URLs, and ensures users are redirected to a central OIDC logout endpoint, providing a consistent single signout experience.
The `sys-front-inj-logout` Ansible role automatically embeds a lightweight JavaScript snippet into your web application's HTML responses. This script identifies logout links, buttons, forms, and other elements, overrides their target URLs, and ensures users are redirected to a central OIDC logout endpoint, providing a consistent single signout experience.
## Overview

View File

@@ -1,6 +1,6 @@
galaxy_info:
author: "Kevin VeenBirkenbach"
role_name: "sys-srv-web-inj-logout"
role_name: "sys-front-inj-logout"
description: >
Injects a JavaScript snippet via Nginx sub_filter that intercepts all logout actions
(links, buttons, forms) and redirects users to a centralized OIDC logout endpoint.
@@ -21,4 +21,4 @@ galaxy_info:
Kevin VeenBirkenbach Consulting & Coaching Solutions https://www.veen.world
repository: "https://s.infinito.nexus/code"
issue_tracker_url: "https://s.infinito.nexus/issues"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-srv-web-inj-logout"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-logout"

View File

@@ -1,8 +1,8 @@
- block:
- include_tasks: 01_core.yml
- set_fact:
run_once_sys_srv_web_inj_logout: true
when: run_once_sys_srv_web_inj_logout is not defined
run_once_sys_front_inj_logout: true
when: run_once_sys_front_inj_logout is not defined
- name: "Load logout code for '{{ application_id }}'"
set_fact:

View File

@@ -0,0 +1 @@
<script src="{{ cdn_urls.shared.js }}/{{ INJ_LOGOUT_JS_FILE_NAME }}{{ lookup('local_mtime_qs', [playbook_dir, 'roles', 'sys-front-inj-logout', 'templates', INJ_LOGOUT_JS_FILE_NAME ~ '.j2'] | path_join) }}"></script>

View File

@@ -0,0 +1,2 @@
INJ_LOGOUT_JS_FILE_NAME: "logout.js"
INJ_LOGOUT_JS_DESTINATION: "{{ [cdn_paths_all.shared.js, INJ_LOGOUT_JS_FILE_NAME] | path_join }}"

View File

@@ -13,7 +13,7 @@ galaxy_info:
- analytics
repository: "https://s.infinito.nexus/code"
issue_tracker_url: "https://s.infinito.nexus/issues"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-srv-web-inj-matomo"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-matomo"
min_ansible_version: "2.9"
platforms:
- name: Any

View File

@@ -4,7 +4,7 @@
name: srv-core
when: run_once_srv_core is not defined
- include_tasks: utils/run_once.yml
when: run_once_sys_srv_web_inj_matomo is not defined
when: run_once_sys_front_inj_matomo is not defined
- name: "Relevant variables for role: {{ role_path | basename }}"
debug:

View File

@@ -1,4 +1,4 @@
SYS_SERVICE_ALL_ENABLED: "{{ MODE_DEBUG }}"
SYS_SERVICE_ALL_ENABLED: "{{ MODE_DEBUG | bool }}"
SYS_SERVICE_DEFAULT_STATE: "{{ 'restarted' if MODE_DEBUG else omit }}"
SYS_SERVICE_DEFAULT_RUNTIME: "86400s" # Maximum total runtime a service is allowed to run before being stopped
SYS_SERVICE_SUPPRESS_FLUSH: [] # Services where the flush should be suppressed

View File

@@ -46,5 +46,5 @@
command: /bin/true
notify: refresh systemctl service
when: not system_service_uses_at
when: (SYS_SERVICE_ALL_ENABLED | bool or system_force_flush | bool)
when: system_force_flush | bool

View File

@@ -6,7 +6,7 @@ system_service_role_dir: "{{ [ playbook_dir, 'roles', system_service_role_
system_service_script_dir: "{{ [ PATH_SYSTEMCTL_SCRIPTS, system_service_id ] | path_join }}"
## Settings
system_force_flush: false # When set to true it activates the flushing of services :)
system_force_flush: "{{ SYS_SERVICE_ALL_ENABLED | bool }}" # When set to true it activates the flushing of services. defaults to SYS_SERVICE_ALL_ENABLED
system_service_suppress_flush: "{{ (system_service_id in SYS_SERVICE_SUPPRESS_FLUSH) | bool }}" # When set to true it suppresses the flushing of services
system_service_copy_files: true # When set to false file copying will be skipped
system_service_timer_enabled: false # When set to true timer will be loaded

View File

@@ -1,29 +0,0 @@
- name: Include dependency 'srv-core'
include_role:
name: srv-core
when: run_once_srv_core is not defined
- name: Generate color palette with colorscheme-generator
set_fact:
color_palette: "{{ lookup('colorscheme', global_css_base_color, count=global_css_count, shades=global_css_shades) }}"
- name: Generate inverted color palette with colorscheme-generator
set_fact:
inverted_color_palette: "{{ lookup('colorscheme', global_css_base_color, count=global_css_count, shades=global_css_shades, invert_lightness=True) }}"
- name: Deploy global.css
template:
src: global.css.j2
dest: "{{ global_css_destination }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'
- name: Get stat for global.css
stat:
path: "{{ global_css_destination }}"
register: global_css_stat
- name: Set global_css_version
set_fact:
global_css_version: "{{ global_css_stat.stat.mtime }}"

View File

@@ -1,4 +0,0 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: utils/run_once.yml
when: run_once_sys_srv_web_inj_css is not defined

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
<link rel="stylesheet" type="text/css" href="/global.css?version={{ global_css_version }}">

View File

@@ -1,3 +0,0 @@
location = /global.css {
root {{ NGINX.DIRECTORIES.DATA.CDN }};
}

View File

@@ -1,4 +0,0 @@
global_css_destination: "{{ NGINX.DIRECTORIES.DATA.CDN }}global.css"
global_css_base_color: "{{ design.css.colors.base }}"
global_css_count: 7
global_css_shades: 100

View File

@@ -1,16 +0,0 @@
- name: Deploy iframe-handler.js
template:
src: iframe-handler.js.j2
dest: "{{ INJ_DESKTOP_JS_FILE_DESTINATION }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'
- name: Get stat for iframe-handler.js
stat:
path: "{{ INJ_DESKTOP_JS_FILE_DESTINATION }}"
register: inj_port_ui_js_stat
- name: Set inj_port_ui_js_version
set_fact:
inj_port_ui_js_version: "{{ inj_port_ui_js_stat.stat.mtime }}"

View File

@@ -1 +0,0 @@
<script src="{{ domains | get_url('web-svc-cdn', WEB_PROTOCOL) }}/{{ INJ_DESKTOP_JS_FILE_NAME }}?{{ inj_port_ui_js_version }}"></script>

View File

@@ -1,2 +0,0 @@
INJ_DESKTOP_JS_FILE_NAME: "iframe-handler.js"
INJ_DESKTOP_JS_FILE_DESTINATION: "{{ [ NGINX.DIRECTORIES.DATA.CDN, INJ_DESKTOP_JS_FILE_NAME ] | path_join }}"

View File

@@ -1 +0,0 @@
<script src="{{ domains | get_url('web-svc-cdn', WEB_PROTOCOL) }}/{{ INJ_LOGOUT_JS_FILE_NAME }}?{{ INJ_LOGOUT_JS_VERSION }}"></script>

View File

@@ -1,2 +0,0 @@
INJ_LOGOUT_JS_FILE_NAME: "logout.js"
INJ_LOGOUT_JS_DESTINATION: "{{ [ NGINX.DIRECTORIES.DATA.CDN, INJ_LOGOUT_JS_FILE_NAME ] | path_join }}"

View File

@@ -8,7 +8,7 @@ This role bootstraps **per-domain Nginx configuration**: it requests TLS certifi
A higher-level orchestration wrapper, *sys-stk-front-proxy* ties together several lower-level roles:
1. **`sys-srv-web-inj-compose`** applies global tweaks and includes.
1. **`sys-front-inj-all`** applies global tweaks and includes.
2. **`sys-svc-certs`** obtains Lets Encrypt certificates.
3. **Domain template deployment** copies a Jinja2 vHost from *srv-proxy-core*.
4. **`web-app-oauth2-proxy`** *(optional)* protects the site with OAuth2.

View File

@@ -1,5 +1,5 @@
# default vhost flavour
vhost_flavour: "basic" # valid: basic | ws_generic
vhost_flavour: "basic" # valid: basic, ws_generic
# build the full template path from the flavour
vhost_template_src: "roles/srv-proxy-core/templates/vhost/{{ vhost_flavour }}.conf.j2"

View File

@@ -0,0 +1,19 @@
# sys-svc-cdn
CDN helper role for building a consistent asset tree, URLs, and on-disk layout.
## Description
Provides compact filters and defaults to define CDN paths, turn them into public URLs, collect required directories, and prepare the filesystem (including a `latest` release link).
## Overview
Defines a per-role CDN structure under `roles/<application_id>/<version>` plus shared and vendor areas. Exposes ready-to-use variables (`cdn`, `cdn_dirs`, `cdn_urls`) and ensures directories exist. Optionally links the current release to `latest`.
## Features
* Jinja filters: `cdn_paths`, `cdn_urls`, `cdn_dirs`
* Variables: `CDN_ROOT`, `CDN_VERSION`, `CDN_BASE_URL`, `cdn`, `cdn_dirs`, `cdn_urls`
* Creates shared/vendor/release directories
* Maintains `roles/<id>/latest` symlink (when version ≠ `latest`)
* Plays nicely with `web-svc-cdn` without circular inclusion

View File

@@ -0,0 +1,17 @@
import os
def cdn_dirs(tree):
out = set()
def walk(v):
if isinstance(v, dict):
for x in v.values(): walk(x)
elif isinstance(v, list):
for x in v: walk(x)
elif isinstance(v, str) and os.path.isabs(v):
out.add(v)
walk(tree)
return sorted(out)
class FilterModule(object):
def filters(self):
return {"cdn_dirs": cdn_dirs}

View File

@@ -0,0 +1,46 @@
import datetime
import os
def cdn_paths(cdn_root, application_id, version):
"""
Build a structured dictionary of all CDN paths for a given application.
Args:
cdn_root (str): Base CDN root, e.g. /var/www/cdn
application_id (str): Role/application identifier
version (str): Release version string (default: current UTC timestamp)
Returns:
dict: Hierarchical CDN path structure
"""
cdn_root = os.path.abspath(cdn_root)
return {
"root": cdn_root,
"shared": {
"root": os.path.join(cdn_root, "_shared"),
"css": os.path.join(cdn_root, "_shared", "css"),
"js": os.path.join(cdn_root, "_shared", "js"),
"img": os.path.join(cdn_root, "_shared", "img"),
"fonts": os.path.join(cdn_root, "_shared", "fonts"),
},
"vendor": os.path.join(cdn_root, "vendor"),
"role": {
"id": application_id,
"root": os.path.join(cdn_root, "roles", application_id),
"version": version,
"release": {
"root": os.path.join(cdn_root, "roles", application_id, version),
"css": os.path.join(cdn_root, "roles", application_id, version, "css"),
"js": os.path.join(cdn_root, "roles", application_id, version, "js"),
"img": os.path.join(cdn_root, "roles", application_id, version, "img"),
"fonts": os.path.join(cdn_root, "roles", application_id, version, "fonts"),
},
},
}
class FilterModule(object):
def filters(self):
return {
"cdn_paths": cdn_paths,
}

View File

@@ -0,0 +1,60 @@
# filter_plugins/cdn_urls.py
import os
def _to_url_tree(obj, cdn_root, base_url):
"""
Recursively walk a nested dict and replace any string paths under cdn_root
with URLs based on base_url. Non-path strings (e.g. role.id, role.version)
are left untouched.
"""
if isinstance(obj, dict):
return {k: _to_url_tree(v, cdn_root, base_url) for k, v in obj.items()}
if isinstance(obj, list):
return [_to_url_tree(v, cdn_root, base_url) for v in obj]
if isinstance(obj, str):
# Normalize inputs
norm_root = os.path.abspath(cdn_root)
norm_val = os.path.abspath(obj)
if norm_val.startswith(norm_root):
# Compute path relative to CDN root and map to URL
rel = os.path.relpath(norm_val, norm_root)
# Handle root itself ('.') → empty path
if rel == ".":
rel = ""
# Always forward slashes for URLs
rel_url = rel.replace(os.sep, "/")
base = base_url.rstrip("/")
return f"{base}/{rel_url}" if rel_url else f"{base}/"
# Non-CDN string → leave as-is (e.g., role.id / role.version)
return obj
# Any other type → return as-is
return obj
def cdn_urls(cdn_dict, base_url):
"""
Create a URL-structured dict from a CDN path dict.
Args:
cdn_dict (dict): output of cdn_paths(...), containing absolute paths
base_url (str): CDN base URL, e.g. https://cdn.example.com
Returns:
dict: same shape as cdn_dict, but with URLs instead of filesystem paths
for any strings pointing under cdn_dict['root'].
Keys like role.id and role.version remain strings as-is.
"""
if not isinstance(cdn_dict, dict) or "root" not in cdn_dict:
raise ValueError("cdn_urls expects a dict from cdn_paths with a 'root' key")
return _to_url_tree(cdn_dict, cdn_dict["root"], base_url)
class FilterModule(object):
def filters(self):
return {
"cdn_urls": cdn_urls,
}

View File

@@ -0,0 +1,24 @@
---
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Prepares and manages the CDN folder structure with shared, vendor, and per-role release directories."
license: "Infinito.Nexus NonCommercial License"
license_url: "https://s.infinito.nexus/license"
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
min_ansible_version: "2.9"
platforms:
- name: Any
versions:
- all
galaxy_tags:
- cdn
- nginx
- assets
- roles
- versioning
repository: "https://s.infinito.nexus/code"
issue_tracker_url: "https://s.infinito.nexus/issues"
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-svc-cdn"

View File

@@ -0,0 +1,30 @@
- name: "Load CDN for '{{ domain }}'"
include_role:
name: web-svc-cdn
public: false
when:
- application_id != 'web-svc-cdn'
- run_once_web_svc_cdn is not defined
- name: Overwritte CDN handlers with neutral handlers
ansible.builtin.include_tasks: "{{ [ playbook_dir, 'tasks/utils/load_handlers.yml'] | path_join }}"
loop:
- svc-prx-openresty
- docker-compose
loop_control:
label: "{{ item }}"
vars:
handler_role_name: "{{ item }}"
# ------------------------------------------------------------------
# Only-once creations (shared root and vendor)
# ------------------------------------------------------------------
- name: Ensure shared root and vendor exist (run once)
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: "0755"
loop: "{{ CDN_DIRS_GLOBAL }}"
- include_tasks: utils/run_once.yml

Some files were not shown because too many files have changed in this diff Show More