From a738841d601940b662b15d8bca94a30400d59e2b Mon Sep 17 00:00:00 2001 From: s4luorth Date: Sun, 7 Jun 2026 15:11:48 +0200 Subject: [PATCH] feat: client-seitige erkennung fuer elementor-videos und JS-iframes - frontend.js erkennt JS-nachgeladene iframes (Slider etc.) per initialem Scan + MutationObserver und ersetzt sie durch den Platzhalter. - Elementor-Video-Widgets: data-settings (youtube_url/vimeo_url) wird gelesen, zu Embed-URL konvertiert, Widget neutralisiert und durch Platzhalter ersetzt. - Service-Daten + i18n werden dafuer ans Frontend lokalisiert (cbConfig). - readme: Erkennung + Grenzen (Elementor-Vorschaubild) dokumentiert. Co-Authored-By: Claude Opus 4.8 --- gdpr-content-blocker/assets/frontend.js | 186 ++++++++++++++++++ .../includes/class-renderer.php | 36 ++++ gdpr-content-blocker/readme.txt | 14 +- 3 files changed, 234 insertions(+), 2 deletions(-) diff --git a/gdpr-content-blocker/assets/frontend.js b/gdpr-content-blocker/assets/frontend.js index b7ef4bf..c254584 100644 --- a/gdpr-content-blocker/assets/frontend.js +++ b/gdpr-content-blocker/assets/frontend.js @@ -8,6 +8,7 @@ 'use strict'; const STORAGE_PREFIX = 'cb_consent_'; + const CFG = window.cbConfig || { services: [], i18n: {} }; /* ───────────────────────── consent storage ───────────────────────── */ @@ -45,6 +46,7 @@ iframe.setAttribute( 'loading', 'lazy' ); iframe.setAttribute( 'allowfullscreen', '' ); iframe.setAttribute( 'referrerpolicy', 'no-referrer-when-downgrade' ); + iframe.setAttribute( 'data-cb-loaded', '1' ); // mark as ours (observer skips) // Set src last — this is the moment the network request is made. iframe.src = src; @@ -108,11 +110,195 @@ window.location.reload(); }; + /* ─────────────── client-side detection (JS-injected iframes) ─────────────── */ + + /** Find a configured service whose match pattern is contained in the URL. */ + function findService( url ) { + if ( ! url ) return null; + const list = CFG.services || []; + for ( let i = 0; i < list.length; i++ ) { + if ( list[ i ].match && url.indexOf( list[ i ].match ) !== -1 ) { + return list[ i ]; + } + } + return null; + } + + /** Convert a YouTube/Vimeo watch URL into its embeddable URL. */ + function toEmbedUrl( url ) { + let m = url.match( /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/ ); + if ( m ) return 'https://www.youtube.com/embed/' + m[ 1 ]; + m = url.match( /(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)/ ); + if ( m ) return 'https://player.vimeo.com/video/' + m[ 1 ]; + return url; + } + + /** Build a consent placeholder element (mirrors PHP render_placeholder). */ + function buildPlaceholder( svc, src, dims ) { + const i18n = CFG.i18n || {}; + dims = dims || {}; + + const wrap = document.createElement( 'div' ); + wrap.className = 'cb-blocker'; + wrap.setAttribute( 'data-cb-id', svc.id ); + wrap.dataset.src = src; + if ( dims.width ) wrap.dataset.width = dims.width; + if ( dims.height ) wrap.dataset.height = dims.height; + if ( dims.height && /^\d+$/.test( String( dims.height ) ) ) { + wrap.style.minHeight = dims.height + 'px'; + } + + const inner = document.createElement( 'div' ); + inner.className = 'cb-blocker__inner'; + + const text = document.createElement( 'p' ); + text.className = 'cb-blocker__text'; + if ( svc.placeholder ) { + text.textContent = svc.placeholder; + } else { + const tpl = i18n.defaultText || '%s'; + const parts = tpl.split( '%s' ); + text.appendChild( document.createTextNode( parts[ 0 ] || '' ) ); + const strong = document.createElement( 'strong' ); + strong.textContent = svc.name; + text.appendChild( strong ); + text.appendChild( document.createTextNode( parts[ 1 ] || '' ) ); + } + inner.appendChild( text ); + + const rec = document.createElement( 'p' ); + rec.className = 'cb-blocker__recipient'; + const recLabel = document.createElement( 'strong' ); + recLabel.textContent = ( i18n.recipient || 'Empfänger:' ) + ' '; + rec.appendChild( recLabel ); + rec.appendChild( document.createTextNode( svc.recipient || '' ) ); + if ( svc.third_country ) { + rec.appendChild( document.createTextNode( ' — ' ) ); + const tc = document.createElement( 'span' ); + tc.className = 'cb-blocker__third-country'; + tc.textContent = i18n.thirdCountry || ''; + rec.appendChild( tc ); + } + inner.appendChild( rec ); + + const pur = document.createElement( 'p' ); + pur.className = 'cb-blocker__purpose'; + const purLabel = document.createElement( 'strong' ); + purLabel.textContent = ( i18n.purpose || 'Zweck:' ) + ' '; + pur.appendChild( purLabel ); + pur.appendChild( document.createTextNode( svc.purpose || '' ) ); + inner.appendChild( pur ); + + if ( svc.privacy_url ) { + const p = document.createElement( 'p' ); + const a = document.createElement( 'a' ); + a.className = 'cb-blocker__privacy-link'; + a.href = svc.privacy_url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = i18n.privacy || 'Datenschutz'; + p.appendChild( a ); + inner.appendChild( p ); + } + + const btn = document.createElement( 'button' ); + btn.type = 'button'; + btn.className = 'cb-blocker__button'; + btn.setAttribute( 'data-cb-id', svc.id ); + btn.textContent = ( i18n.load || '%s' ).replace( '%s', svc.name ); + inner.appendChild( btn ); + + const label = document.createElement( 'label' ); + label.className = 'cb-blocker__remember'; + const cb = document.createElement( 'input' ); + cb.type = 'checkbox'; + cb.className = 'cb-blocker__remember-cb'; + cb.checked = true; + label.appendChild( cb ); + label.appendChild( document.createTextNode( ' ' + ( i18n.remember || '' ) ) ); + inner.appendChild( label ); + + wrap.appendChild( inner ); + return wrap; + } + + /** Replace a freshly seen iframe with a placeholder (or let it load if consented). */ + function handleIframe( iframe ) { + if ( iframe.dataset.cbLoaded ) return; // one we created + if ( iframe.closest( '.cb-blocker' ) ) return; // inside a placeholder + const rawSrc = iframe.getAttribute( 'src' ) || ''; + const svc = findService( rawSrc ); + if ( ! svc ) return; + + if ( hasConsent( svc.id ) ) return; // already consented → leave it + + iframe.setAttribute( 'src', 'about:blank' ); // stop the request ASAP + const dims = { + width: iframe.getAttribute( 'width' ) || '', + height: iframe.getAttribute( 'height' ) || '', + }; + const placeholder = buildPlaceholder( svc, rawSrc, dims ); + if ( iframe.parentNode ) { + iframe.parentNode.replaceChild( placeholder, iframe ); + } + } + + /** Elementor video widgets store the URL in data-settings and build the + * player via JS — handle them before that happens. */ + function handleElementorVideos() { + document.querySelectorAll( '.elementor-widget-video[data-settings]' ).forEach( function ( widget ) { + let settings; + try { + settings = JSON.parse( widget.dataset.settings ); + } catch ( e ) { + return; + } + const raw = settings.youtube_url || settings.vimeo_url || settings.external_url || ''; + if ( ! raw ) return; + + const embed = toEmbedUrl( raw ); + const svc = findService( raw ) || findService( embed ); + if ( ! svc ) return; + + if ( hasConsent( svc.id ) ) return; // consented → let Elementor build it + + // Neutralise Elementor so it won't build the player / load the overlay. + widget.removeAttribute( 'data-settings' ); + const container = widget.querySelector( '.elementor-widget-container' ) || widget; + container.innerHTML = ''; + container.appendChild( buildPlaceholder( svc, embed, {} ) ); + } ); + } + + function scanExistingIframes() { + document.querySelectorAll( 'iframe[src]' ).forEach( handleIframe ); + } + + function startObserver() { + if ( typeof MutationObserver === 'undefined' ) return; + const obs = new MutationObserver( function ( mutations ) { + for ( const m of mutations ) { + for ( const node of m.addedNodes ) { + if ( node.nodeType !== 1 ) continue; + if ( node.tagName === 'IFRAME' ) { + handleIframe( node ); + } else if ( node.querySelectorAll ) { + node.querySelectorAll( 'iframe[src]' ).forEach( handleIframe ); + } + } + } + } ); + obs.observe( document.documentElement, { childList: true, subtree: true } ); + } + /* ───────────────────────── bootstrap ─────────────────────────────── */ function init() { loadPreConsented(); attachButtons(); + handleElementorVideos(); + scanExistingIframes(); + startObserver(); } if ( document.readyState === 'loading' ) { diff --git a/gdpr-content-blocker/includes/class-renderer.php b/gdpr-content-blocker/includes/class-renderer.php index a73164d..fbe39cd 100644 --- a/gdpr-content-blocker/includes/class-renderer.php +++ b/gdpr-content-blocker/includes/class-renderer.php @@ -45,6 +45,42 @@ class CB_Renderer { CB_VERSION, true ); + + // Service data + i18n for client-side detection (JS-injected iframes such + // as Elementor video widgets, sliders, …). + wp_localize_script( 'cb-frontend', 'cbConfig', [ + 'services' => self::services_for_js(), + 'i18n' => [ + 'recipient' => __( 'Empfänger:', 'gdpr-content-blocker' ), + 'purpose' => __( 'Zweck:', 'gdpr-content-blocker' ), + 'thirdCountry' => __( '⚠ Datenübermittlung in ein Drittland außerhalb der EU/des EWR', 'gdpr-content-blocker' ), + 'privacy' => __( 'Datenschutzerklärung des Anbieters', 'gdpr-content-blocker' ), + 'load' => __( '%s jetzt laden', 'gdpr-content-blocker' ), + 'remember' => __( 'Diesen Dienst künftig immer laden', 'gdpr-content-blocker' ), + 'defaultText' => __( 'Um diesen Inhalt von %s zu laden, ist Ihre Einwilligung erforderlich. Dabei werden personenbezogene Daten (z. B. Ihre IP-Adresse) an den Anbieter übertragen.', 'gdpr-content-blocker' ), + ], + ] ); + } + + /** Minimal, public service data for client-side detection. */ + private static function services_for_js(): array { + $out = []; + foreach ( CB_Settings::get_services() as $svc ) { + if ( empty( $svc['match_pattern'] ) || ! ( $svc['enabled'] ?? true ) ) { + continue; + } + $out[] = [ + 'id' => $svc['id'], + 'name' => $svc['name'], + 'match' => $svc['match_pattern'], + 'recipient' => $svc['recipient'], + 'third_country' => ! empty( $svc['third_country'] ), + 'purpose' => $svc['purpose'], + 'privacy_url' => $svc['privacy_url'] ?? '', + 'placeholder' => $svc['placeholder_text'] ?? '', + ]; + } + return $out; } /** diff --git a/gdpr-content-blocker/readme.txt b/gdpr-content-blocker/readme.txt index bfa53a8..cc26558 100644 --- a/gdpr-content-blocker/readme.txt +++ b/gdpr-content-blocker/readme.txt @@ -76,10 +76,20 @@ Veröffentlichung (für den Betreiber): Plugin-ZIP per Gitea-Actions (Tag `v*`) manuell per curl an den Endpoint `POST /api/v1/releases` des Backends laden. Die ZIP muss einen Ordner `gdpr-content-blocker/` auf oberster Ebene enthalten. +== Erkennung == + +* Server-seitig: iframes im initialen HTML werden anhand der Erkennungsmuster blockiert. +* Client-seitig (automatisch): per JavaScript nachgeladene iframes (z. B. Slider) sowie + Elementor-Video-Widgets werden erkannt und durch den Platzhalter ersetzt. + == Bekannte Grenzen == -* Auto-Erkennung greift nur auf iframes im initialen Server-HTML. Durch JavaScript nachgeladene iframes - werden nicht automatisch erkannt. Für diese Fälle den manuellen Shortcode verwenden. +* Client-seitige Erkennung ist „best effort": Bei per JS nachgeladenen iframes kann im + Einzelfall ein bereits gestarteter Request nicht garantiert verhindert werden. Für + maximale Rechtssicherheit den manuellen Shortcode verwenden. +* Bei Elementor-Videos mit Bild-Overlay kann das Vorschaubild (z. B. von ytimg.com) als + CSS-Hintergrund bereits beim Laden angefragt werden. Empfehlung: in Elementor das + Bild-Overlay deaktivieren oder ein eigenes Vorschaubild verwenden. == Changelog ==