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 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-07 15:11:48 +02:00
parent 3c37bf63cc
commit a738841d60
3 changed files with 234 additions and 2 deletions

View File

@@ -8,6 +8,7 @@
'use strict'; 'use strict';
const STORAGE_PREFIX = 'cb_consent_'; const STORAGE_PREFIX = 'cb_consent_';
const CFG = window.cbConfig || { services: [], i18n: {} };
/* ───────────────────────── consent storage ───────────────────────── */ /* ───────────────────────── consent storage ───────────────────────── */
@@ -45,6 +46,7 @@
iframe.setAttribute( 'loading', 'lazy' ); iframe.setAttribute( 'loading', 'lazy' );
iframe.setAttribute( 'allowfullscreen', '' ); iframe.setAttribute( 'allowfullscreen', '' );
iframe.setAttribute( 'referrerpolicy', 'no-referrer-when-downgrade' ); 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. // Set src last — this is the moment the network request is made.
iframe.src = src; iframe.src = src;
@@ -108,11 +110,195 @@
window.location.reload(); 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 ─────────────────────────────── */ /* ───────────────────────── bootstrap ─────────────────────────────── */
function init() { function init() {
loadPreConsented(); loadPreConsented();
attachButtons(); attachButtons();
handleElementorVideos();
scanExistingIframes();
startObserver();
} }
if ( document.readyState === 'loading' ) { if ( document.readyState === 'loading' ) {

View File

@@ -45,6 +45,42 @@ class CB_Renderer {
CB_VERSION, CB_VERSION,
true 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;
} }
/** /**

View File

@@ -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 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. 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 == == Bekannte Grenzen ==
* Auto-Erkennung greift nur auf iframes im initialen Server-HTML. Durch JavaScript nachgeladene iframes * Client-seitige Erkennung ist „best effort": Bei per JS nachgeladenen iframes kann im
werden nicht automatisch erkannt. Für diese Fälle den manuellen Shortcode verwenden. 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 == == Changelog ==