chore: monorepo - plugin, backend und hilfsdaten in einem repo

- Eltern-Ordner ist jetzt EIN Git-Repo (statt getrennter Repos).
- root .gitignore haelt Secrets (.env), node_modules, DB und Build-Artefakte raus.
- release.ps1: manueller Release (ZIP bauen + ans Backend laden).
- root README mit Struktur und Release-Ablauf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-06-07 14:41:38 +02:00
commit ecb5e1bd22
37 changed files with 4390 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Auto-detects iframes in the rendered HTML and replaces them with consent
* placeholders based on configured match patterns.
*
* Strategy (safe by design):
* 1. A regex LOCATES each <iframe>…</iframe> block (it does NOT parse attributes).
* 2. Each block is parsed individually with DOMDocument to read the src reliably.
* 3. Only the matched iframe block is replaced in the original HTML via callback.
*
* The rest of the page HTML is never re-serialized, so themes, page builders,
* inline JS and JSON-LD stay byte-for-byte intact.
*
* Known limitation: Only iframes present in the initial server-rendered HTML are
* covered here. For iframes injected later by JavaScript, use the manual shortcode
* [content_blocker id="…"] around the embed.
*/
class CB_Autodetect {
public static function init(): void {
add_action( 'template_redirect', [ __CLASS__, 'start_buffer' ] );
}
public static function start_buffer(): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
return;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
return;
}
$services = CB_Settings::get_services();
$active = array_values( array_filter(
$services,
fn( $s ) => ! empty( $s['match_pattern'] ) && ( $s['enabled'] ?? true )
) );
if ( empty( $active ) ) {
return;
}
ob_start( fn( string $html ) => self::process( $html, $active ) );
}
public static function process( string $html, array $services ): string {
if ( $html === '' || stripos( $html, '<iframe' ) === false ) {
return $html;
}
// Locate iframe blocks only. Attribute parsing happens via DOMDocument below.
return (string) preg_replace_callback(
'#<iframe\b[^>]*>.*?</iframe>#is',
function ( array $m ) use ( $services ): string {
return self::maybe_replace_iframe( $m[0], $services );
},
$html
);
}
/**
* Parse a single iframe block with DOMDocument, read its src, and return either
* the consent placeholder (on match) or the unchanged original block.
*/
private static function maybe_replace_iframe( string $iframe_html, array $services ): string {
$src = self::get_src( $iframe_html );
if ( $src === '' ) {
return $iframe_html;
}
foreach ( $services as $svc ) {
$pattern = $svc['match_pattern'] ?? '';
if ( $pattern !== '' && str_contains( $src, $pattern ) ) {
$attrs = CB_Renderer::extract_iframe_attrs( $iframe_html );
$dims = [ 'width' => $attrs['width'], 'height' => $attrs['height'] ];
return CB_Renderer::render_placeholder( $svc, $src, $dims );
}
}
return $iframe_html;
}
/** Reliably extract the src attribute of a single iframe via DOMDocument. */
private static function get_src( string $iframe_html ): string {
$dom = new DOMDocument();
libxml_use_internal_errors( true );
// Wrap so loadHTML has a clean context; force UTF-8 so umlauts survive.
$dom->loadHTML(
'<?xml encoding="utf-8"?><div>' . $iframe_html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$node = $dom->getElementsByTagName( 'iframe' )->item( 0 );
if ( ! $node instanceof DOMElement ) {
return '';
}
$src = trim( $node->getAttribute( 'src' ) );
// Normalise protocol-relative and HTML-entity-encoded ampersands.
$src = str_replace( '&amp;', '&', $src );
return $src;
}
}

View File

@@ -0,0 +1,405 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* License handling: activates a key against the (self-hosted) license backend,
* binds it to this site's domain, and re-checks daily.
*
* Design decision (deliberate): an invalid/expired license NEVER disables the
* actual content blocking. Silently switching protection off on a GDPR plugin
* would create a data-protection hole for the customer. Instead we show an admin
* notice and gate non-critical extras (e.g. update delivery). Core blocking
* always stays on.
*/
class CB_License {
const CRON_HOOK = 'cb_license_daily_check';
public static function init(): void {
add_action( 'admin_post_cb_license_activate', [ __CLASS__, 'handle_activate' ] );
add_action( 'admin_post_cb_license_deactivate', [ __CLASS__, 'handle_deactivate' ] );
add_action( 'admin_post_cb_license_swap', [ __CLASS__, 'handle_swap' ] );
add_action( self::CRON_HOOK, [ __CLASS__, 'cron_check' ] );
add_action( 'admin_notices', [ __CLASS__, 'maybe_notice' ] );
// No license → no updates. Strip any update offer for this plugin when
// the license is not active. (The update source is served by the backend
// only to licensed sites; this filter enforces it client-side too.)
add_filter( 'site_transient_update_plugins', [ __CLASS__, 'gate_updates' ] );
}
/** Remove this plugin from the update list unless the license is active. */
public static function gate_updates( mixed $transient ): mixed {
if ( self::is_active() || ! is_object( $transient ) ) {
return $transient;
}
$basename = plugin_basename( CB_FILE );
if ( isset( $transient->response[ $basename ] ) ) {
unset( $transient->response[ $basename ] );
}
return $transient;
}
public static function on_activation(): void {
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::CRON_HOOK );
}
}
public static function on_deactivation(): void {
$ts = wp_next_scheduled( self::CRON_HOOK );
if ( $ts ) {
wp_unschedule_event( $ts, self::CRON_HOOK );
}
}
/* ───────────────────────── helpers ───────────────────────── */
public static function get_license(): array {
$saved = get_option( CB_LICENSE_OPTION, [] );
$lic = wp_parse_args( is_array( $saved ) ? $saved : [], [
'key' => '',
'status' => 'inactive', // inactive | active | invalid | limit
'domain' => '',
'last_check' => 0,
'message' => '',
'pending_domains' => [],
] );
if ( ! is_array( $lic['pending_domains'] ) ) {
$lic['pending_domains'] = [];
}
return $lic;
}
public static function is_active(): bool {
return self::get_license()['status'] === 'active';
}
/** Mask a key for display: keep dashes + last 4 chars, hide the rest. */
public static function mask_key( string $key ): string {
$n = strlen( $key );
if ( $n <= 4 ) {
return $key;
}
$tail = substr( $key, -4 );
$head = preg_replace( '/[A-Za-z0-9]/', '•', substr( $key, 0, $n - 4 ) );
return $head . $tail;
}
public static function api_url(): string {
return untrailingslashit( (string) apply_filters( 'cb_license_api_url', CB_LICENSE_API_URL ) );
}
public static function domain(): string {
$host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
$host = strtolower( $host );
return (string) preg_replace( '/^www\./', '', $host );
}
/** POST JSON to a backend endpoint. Returns decoded array or WP_Error. */
private static function remote( string $endpoint, array $body ): array|WP_Error {
$response = wp_remote_post( self::api_url() . $endpoint, [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new WP_Error( 'cb_bad_response', __( 'Ungültige Antwort vom Lizenzserver.', 'gdpr-content-blocker' ) );
}
$data['_http_code'] = $code;
return $data;
}
/* ───────────────────────── actions ───────────────────────── */
public static function handle_activate(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_license', 'cb_license_nonce' );
$key = isset( $_POST['cb_license_key'] )
? sanitize_text_field( wp_unslash( $_POST['cb_license_key'] ) )
: '';
$key = trim( $key );
if ( $key === '' ) {
self::store( '', 'inactive', __( 'Bitte einen Lizenzschlüssel eingeben.', 'gdpr-content-blocker' ) );
self::redirect();
}
$result = self::remote( '/api/v1/activate', [
'key' => $key,
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
if ( is_wp_error( $result ) ) {
self::store( $key, 'inactive', $result->get_error_message() );
self::redirect();
}
if ( ! empty( $result['ok'] ) && ( $result['status'] ?? '' ) === 'valid' ) {
self::store( $key, 'active', __( 'Lizenz erfolgreich aktiviert.', 'gdpr-content-blocker' ) );
} elseif ( ( $result['code'] ?? '' ) === 'limit_reached' ) {
// No free slots: keep the key and offer to release one of the bound domains.
$domains = is_array( $result['domains'] ?? null ) ? $result['domains'] : [];
self::store(
$key,
'limit',
__( 'Alle Plätze dieser Lizenz sind belegt. Geben Sie eine Domain frei, um diese Seite zu aktivieren.', 'gdpr-content-blocker' ),
$domains
);
} else {
$msg = $result['error'] ?? __( 'Aktivierung fehlgeschlagen.', 'gdpr-content-blocker' );
self::store( $key, 'invalid', $msg );
}
self::redirect();
}
/**
* Free a chosen domain's slot on the backend, then activate the current site.
* Triggered from the "no free slots" selection UI.
*/
public static function handle_swap(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_license', 'cb_license_nonce' );
$lic = self::get_license();
$key = $lic['key'];
if ( $key === '' ) {
self::redirect();
}
$release = isset( $_POST['cb_release_domain'] )
? sanitize_text_field( wp_unslash( $_POST['cb_release_domain'] ) )
: '';
// Only allow releasing a domain that was actually reported as occupied.
if ( $release === '' || ! in_array( $release, $lic['pending_domains'], true ) ) {
self::store( $key, 'limit', __( 'Bitte eine gültige Domain zum Freigeben auswählen.', 'gdpr-content-blocker' ), $lic['pending_domains'] );
self::redirect();
}
// 1) Release the chosen domain.
$deact = self::remote( '/api/v1/deactivate', [
'key' => $key,
'product' => CB_PRODUCT_SLUG,
'domain' => $release,
] );
if ( is_wp_error( $deact ) ) {
self::store( $key, 'limit', $deact->get_error_message(), $lic['pending_domains'] );
self::redirect();
}
// 2) Activate this site.
$result = self::remote( '/api/v1/activate', [
'key' => $key,
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
if ( is_wp_error( $result ) ) {
self::store( $key, 'limit', $result->get_error_message(), $lic['pending_domains'] );
} elseif ( ! empty( $result['ok'] ) && ( $result['status'] ?? '' ) === 'valid' ) {
self::store( $key, 'active', sprintf(
/* translators: %s: released domain */
__( 'Domain %s freigegeben und diese Seite aktiviert.', 'gdpr-content-blocker' ),
$release
) );
} else {
$domains = is_array( $result['domains'] ?? null ) ? $result['domains'] : $lic['pending_domains'];
$msg = $result['error'] ?? __( 'Aktivierung fehlgeschlagen.', 'gdpr-content-blocker' );
self::store( $key, 'limit', $msg, $domains );
}
self::redirect();
}
public static function handle_deactivate(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_license', 'cb_license_nonce' );
$lic = self::get_license();
if ( $lic['key'] !== '' ) {
// Free the activation slot on the backend (best effort).
self::remote( '/api/v1/deactivate', [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
}
delete_option( CB_LICENSE_OPTION );
CB_Updater::flush_cache();
delete_site_transient( 'update_plugins' );
self::redirect();
}
public static function cron_check(): void {
$lic = self::get_license();
// Only re-validate licenses that are bound to THIS domain. In the
// 'inactive' and 'limit' states this site isn't activated, so /validate
// would always 403 and wrongly overwrite the (recoverable) state.
if ( $lic['key'] === '' || ! in_array( $lic['status'], [ 'active', 'invalid' ], true ) ) {
return;
}
$result = self::remote( '/api/v1/validate', [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => self::domain(),
] );
// On network error: keep the previous status (grace), just record the time.
if ( is_wp_error( $result ) ) {
self::store( $lic['key'], $lic['status'], $lic['message'] );
return;
}
if ( ! empty( $result['ok'] ) && ( $result['status'] ?? '' ) === 'valid' ) {
self::store( $lic['key'], 'active', '' );
} else {
$msg = $result['error'] ?? __( 'Lizenz nicht mehr gültig.', 'gdpr-content-blocker' );
self::store( $lic['key'], 'invalid', $msg );
}
}
private static function store( string $key, string $status, string $message, array $pending_domains = [] ): void {
update_option( CB_LICENSE_OPTION, [
'key' => $key,
'status' => $status,
'domain' => self::domain(),
'last_check' => time(),
'message' => $message,
'pending_domains' => array_values( array_filter( array_map( 'strval', $pending_domains ) ) ),
] );
// License state changed → invalidate cached update info and force WP to
// re-evaluate available updates (so the gate takes effect immediately).
if ( class_exists( 'CB_Updater' ) ) {
CB_Updater::flush_cache();
}
delete_site_transient( 'update_plugins' );
}
private static function redirect(): void {
wp_safe_redirect( admin_url( 'options-general.php?page=gdpr-content-blocker&cb_tab=license' ) );
exit;
}
/* ───────────────────────── UI ───────────────────────── */
public static function maybe_notice(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$screen = get_current_screen();
if ( $screen && $screen->id === 'settings_page_gdpr-content-blocker' ) {
return; // The tab already shows the status; don't double up.
}
if ( self::is_active() ) {
return;
}
$url = admin_url( 'options-general.php?page=gdpr-content-blocker&cb_tab=license' );
echo '<div class="notice notice-warning"><p>';
printf(
/* translators: %s: link to the license settings */
esc_html__( 'GDPR Content Blocker ist nicht lizenziert. Der Schutz bleibt aktiv, aber für Updates und Support bitte %s hinterlegen.', 'gdpr-content-blocker' ),
'<a href="' . esc_url( $url ) . '">' . esc_html__( 'Lizenzschlüssel', 'gdpr-content-blocker' ) . '</a>'
);
echo '</p></div>';
}
public static function render_tab(): void {
$lic = self::get_license();
$status = $lic['status'];
$badge = match ( $status ) {
'active' => '<span style="color:#1a7f37;font-weight:600;">● ' . esc_html__( 'Aktiv', 'gdpr-content-blocker' ) . '</span>',
'invalid' => '<span style="color:#b32d2e;font-weight:600;">● ' . esc_html__( 'Ungültig', 'gdpr-content-blocker' ) . '</span>',
'limit' => '<span style="color:#b32d2e;font-weight:600;">● ' . esc_html__( 'Alle Plätze belegt', 'gdpr-content-blocker' ) . '</span>',
default => '<span style="color:#8a6d3b;font-weight:600;">● ' . esc_html__( 'Nicht aktiviert', 'gdpr-content-blocker' ) . '</span>',
};
?>
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Status', 'gdpr-content-blocker' ); ?></th>
<td><?php echo wp_kses_post( $badge ); ?>
<?php if ( $lic['message'] !== '' ) : ?>
<p class="description"><?php echo esc_html( $lic['message'] ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Domain', 'gdpr-content-blocker' ); ?></th>
<td><code><?php echo esc_html( self::domain() ); ?></code></td>
</tr>
</tbody></table>
<?php if ( $status === 'limit' && ! empty( $lic['pending_domains'] ) ) : ?>
<!-- No free slots: let the user release one bound domain and activate here. -->
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'cb_license', 'cb_license_nonce' ); ?>
<input type="hidden" name="action" value="cb_license_swap">
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><label for="cb_release_domain"><?php esc_html_e( 'Domain freigeben', 'gdpr-content-blocker' ); ?></label></th>
<td>
<select id="cb_release_domain" name="cb_release_domain">
<?php foreach ( $lic['pending_domains'] as $d ) : ?>
<option value="<?php echo esc_attr( $d ); ?>"><?php echo esc_html( $d ); ?></option>
<?php endforeach; ?>
</select>
<p class="description">
<?php esc_html_e( 'Diese Lizenz ist bereits auf den genannten Domains aktiv. Wählen Sie eine zum Freigeben aus sie wird deaktiviert und diese Seite stattdessen aktiviert.', 'gdpr-content-blocker' ); ?>
</p>
</td>
</tr>
</tbody></table>
<?php submit_button( __( 'Freigeben und diese Seite aktivieren', 'gdpr-content-blocker' ), 'primary' ); ?>
</form>
<hr>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'cb_license', 'cb_license_nonce' ); ?>
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><label for="cb_license_key"><?php esc_html_e( 'Lizenzschlüssel', 'gdpr-content-blocker' ); ?></label></th>
<td>
<?php if ( $status === 'active' ) : ?>
<input type="text" id="cb_license_key" class="regular-text code"
value="<?php echo esc_attr( self::mask_key( $lic['key'] ) ); ?>" readonly disabled>
<p class="description"><?php esc_html_e( 'Aus Sicherheitsgründen verdeckt. Zum Ändern bitte zuerst deaktivieren.', 'gdpr-content-blocker' ); ?></p>
<?php else : ?>
<input type="text" id="cb_license_key" name="cb_license_key" class="regular-text code"
value="<?php echo esc_attr( $lic['key'] ); ?>"
placeholder="XXXX-XXXX-XXXX-XXXX">
<?php endif; ?>
</td>
</tr>
</tbody></table>
<?php if ( $status === 'active' ) : ?>
<input type="hidden" name="action" value="cb_license_deactivate">
<?php submit_button( __( 'Lizenz deaktivieren', 'gdpr-content-blocker' ), 'secondary' ); ?>
<?php else : ?>
<input type="hidden" name="action" value="cb_license_activate">
<?php submit_button( $status === 'limit' ? __( 'Erneut versuchen', 'gdpr-content-blocker' ) : __( 'Lizenz aktivieren', 'gdpr-content-blocker' ), 'secondary' ); ?>
<?php endif; ?>
</form>
<?php
}
}

View File

@@ -0,0 +1,285 @@
<?php
defined( 'ABSPATH' ) || exit;
class CB_Renderer {
public static function init(): void {
add_shortcode( 'content_blocker', [ __CLASS__, 'shortcode' ] );
add_shortcode( 'content_blocker_revoke', [ __CLASS__, 'revoke_shortcode' ] );
add_shortcode( 'content_blocker_services', [ __CLASS__, 'services_shortcode' ] );
add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] );
}
public static function enqueue_assets(): void {
wp_enqueue_style(
'cb-frontend',
CB_URL . 'assets/frontend.css',
[],
CB_VERSION
);
$style = CB_Settings::get_style();
// Build one inline block: CSS variables first, the user's Custom-CSS LAST
// so it always overrides. Emitted as a single wp_add_inline_style call.
$inline = sprintf(
':root{--cb-text:%s;--cb-bg:%s;--cb-btn-bg:%s;--cb-btn-text:%s;--cb-btn-hover-bg:%s;--cb-btn-hover-text:%s;}',
esc_attr( $style['text_color'] ),
esc_attr( $style['bg_color'] ),
esc_attr( $style['button_bg'] ),
esc_attr( $style['button_text'] ),
esc_attr( $style['button_hover_bg'] ),
esc_attr( $style['button_hover_text'] )
);
if ( trim( (string) $style['custom_css'] ) !== '' ) {
$inline .= "\n/* gdpr-content-blocker custom css */\n" . $style['custom_css'];
}
wp_add_inline_style( 'cb-frontend', $inline );
wp_enqueue_script(
'cb-frontend',
CB_URL . 'assets/frontend.js',
[],
CB_VERSION,
true
);
}
/**
* Shortcode: [content_blocker id="google-maps"]<iframe ...></iframe>[/content_blocker]
*/
public static function shortcode( array $atts, ?string $content = null ): string {
$atts = shortcode_atts( [ 'id' => '' ], $atts, 'content_blocker' );
$id = sanitize_key( $atts['id'] );
if ( $id === '' ) {
return '';
}
$svc = CB_Settings::get_service( $id );
if ( $svc === null ) {
return '<!-- gdpr-content-blocker: unknown service id "' . esc_html( $id ) . '" -->';
}
// Disabled blocker → let the embedded content load normally (no blocking).
if ( ! ( $svc['enabled'] ?? true ) ) {
return $content ?? '';
}
// Extract src + dimensions from the inner iframe
$src = '';
$dims = [];
if ( $content !== null && $content !== '' ) {
$attrs = self::extract_iframe_attrs( $content );
$src = $attrs['src'];
$dims = [ 'width' => $attrs['width'], 'height' => $attrs['height'] ];
}
return self::render_placeholder( $svc, $src, $dims );
}
/**
* Shortcode: [content_blocker_revoke]
* Renders, by default, a visible text link (not a button) so it doesn't get
* confused with a cookie banner's revoke control. Attributes:
* text="…" custom link label
* style="link|button" default "link"
* note="yes|no" show the clarifying hint (default "yes")
*/
public static function revoke_shortcode( array|string $atts = [] ): string {
$atts = shortcode_atts( [
'text' => __( 'Einwilligung für externe Inhalte widerrufen', 'gdpr-content-blocker' ),
'style' => 'link',
'note' => 'yes',
], $atts, 'content_blocker_revoke' );
$class = $atts['style'] === 'button' ? 'cb-revoke-btn' : 'cb-revoke-link';
$out = '<a href="#" class="' . esc_attr( $class ) . '" role="button" '
. 'onclick="cbRevokeAll();return false;">'
. esc_html( $atts['text'] )
. '</a>';
if ( $atts['note'] === 'yes' ) {
$out .= '<span class="cb-revoke-note">'
. esc_html__( 'Betrifft nur die Freigabe externer Einbettungen (z. B. Karten, Videos). Cookie-Einstellungen werden separat verwaltet.', 'gdpr-content-blocker' )
. '</span>';
}
return $out;
}
/**
* Shortcode: [content_blocker_services]
* Lists every configured third-party service with the Art. 13 details
* (provider, recipient, third-country note, purpose, privacy link).
* Meant for embedding in the privacy policy.
*/
public static function services_shortcode(): string {
$services = CB_Settings::get_services();
if ( empty( $services ) ) {
return '';
}
$out = '<ul class="cb-services-list">';
foreach ( $services as $svc ) {
// Only list active blockers — disabled ones aren't being managed.
if ( ! ( $svc['enabled'] ?? true ) ) {
continue;
}
$name = esc_html( $svc['name'] ?? '' );
if ( $name === '' ) {
continue;
}
$out .= '<li class="cb-service-entry">';
$out .= '<span class="cb-service-entry__name">' . $name . '</span>';
$out .= '<dl>';
if ( ! empty( $svc['recipient'] ) ) {
$recipient = esc_html( $svc['recipient'] );
if ( ! empty( $svc['third_country'] ) ) {
$recipient .= ' (' . esc_html__( 'Übermittlung in ein Drittland außerhalb der EU/des EWR', 'gdpr-content-blocker' ) . ')';
}
$out .= '<dt>' . esc_html__( 'Empfänger', 'gdpr-content-blocker' ) . '</dt><dd>' . $recipient . '</dd>';
}
if ( ! empty( $svc['purpose'] ) ) {
$out .= '<dt>' . esc_html__( 'Zweck', 'gdpr-content-blocker' ) . '</dt><dd>' . esc_html( $svc['purpose'] ) . '</dd>';
}
$out .= '<dt>' . esc_html__( 'Setzt Cookies', 'gdpr-content-blocker' ) . '</dt><dd>'
. ( ! empty( $svc['sets_cookie'] ) ? esc_html__( 'Ja', 'gdpr-content-blocker' ) : esc_html__( 'Nein', 'gdpr-content-blocker' ) )
. '</dd>';
if ( ! empty( $svc['privacy_url'] ) ) {
$url = esc_url( $svc['privacy_url'] );
$out .= '<dt>' . esc_html__( 'Datenschutz', 'gdpr-content-blocker' ) . '</dt>'
. '<dd><a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a></dd>';
}
$out .= '</dl></li>';
}
$out .= '</ul>';
return $out;
}
/**
* Build the placeholder HTML for a given service.
* $src is the real iframe URL (empty string if unknown).
* $dims may contain 'width' and 'height' (numbers or e.g. "100%") taken
* from the original iframe so the placeholder reserves the same height.
*/
public static function render_placeholder( array $svc, string $src = '', array $dims = [] ): string {
$id = esc_attr( $svc['id'] );
$name = esc_html( $svc['name'] );
$recipient = esc_html( $svc['recipient'] );
$purpose = esc_html( $svc['purpose'] );
$privacy_url = esc_url( $svc['privacy_url'] ?? '' );
$third = ! empty( $svc['third_country'] );
$custom_text = $svc['placeholder_text'] ?? '';
if ( $custom_text !== '' ) {
$info_text = esc_html( $custom_text );
} else {
$info_text = sprintf(
/* translators: %s: provider name */
esc_html__( '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' ),
'<strong>' . $name . '</strong>'
);
}
$third_note = '';
if ( $third ) {
$third_note = '<span class="cb-blocker__third-country">'
. esc_html__( '⚠ Datenübermittlung in ein Drittland außerhalb der EU/des EWR', 'gdpr-content-blocker' )
. '</span>';
}
$privacy_link = '';
if ( $privacy_url !== '' ) {
$privacy_link = '<a href="' . $privacy_url . '" target="_blank" rel="noopener noreferrer" class="cb-blocker__privacy-link">'
. esc_html__( 'Datenschutzerklärung des Anbieters', 'gdpr-content-blocker' )
. '</a>';
}
$data_src = $src !== '' ? ' data-src="' . esc_attr( $src ) . '"' : '';
// Reserve the embed's height so the layout doesn't jump, and remember the
// original dimensions for the iframe that gets created on consent.
$width = isset( $dims['width'] ) ? (string) $dims['width'] : '';
$height = isset( $dims['height'] ) ? (string) $dims['height'] : '';
$style_at = '';
if ( $height !== '' && ctype_digit( $height ) ) {
$style_at = ' style="min-height:' . esc_attr( $height ) . 'px"';
}
$data_dims = '';
if ( $width !== '' ) {
$data_dims .= ' data-width="' . esc_attr( $width ) . '"';
}
if ( $height !== '' ) {
$data_dims .= ' data-height="' . esc_attr( $height ) . '"';
}
return '<div class="cb-blocker" data-cb-id="' . $id . '"' . $data_src . $data_dims . $style_at . '>'
. '<div class="cb-blocker__inner">'
. '<p class="cb-blocker__text">' . $info_text . '</p>'
. '<p class="cb-blocker__recipient">'
. '<strong>' . esc_html__( 'Empfänger:', 'gdpr-content-blocker' ) . '</strong> '
. $recipient
. ( $third_note !== '' ? ' &mdash; ' . $third_note : '' )
. '</p>'
. '<p class="cb-blocker__purpose">'
. '<strong>' . esc_html__( 'Zweck:', 'gdpr-content-blocker' ) . '</strong> '
. $purpose
. '</p>'
. ( $privacy_link !== '' ? '<p>' . $privacy_link . '</p>' : '' )
. '<button type="button" class="cb-blocker__button" data-cb-id="' . $id . '">'
. sprintf(
/* translators: %s: provider name */
esc_html__( '%s jetzt laden', 'gdpr-content-blocker' ),
$name
)
. '</button>'
. '</div>'
. '</div>';
}
/** Pull the src attribute out of an iframe string. */
public static function extract_iframe_src( string $html ): string {
return self::extract_iframe_attrs( $html )['src'];
}
/**
* Pull src + width + height out of an iframe string.
* Height is also read from an inline style="height:NNNpx" if no attribute.
* Returns [ 'src' => string, 'width' => string, 'height' => string ].
*/
public static function extract_iframe_attrs( string $html ): array {
$src = '';
if ( preg_match( '/\bsrc=["\']([^"\']+)["\']/i', $html, $m ) ) {
$src = esc_url_raw( $m[1] );
}
$width = '';
if ( preg_match( '/\bwidth=["\']?(\d+(?:%|px)?)["\']?/i', $html, $m ) ) {
$width = $m[1];
}
$height = '';
if ( preg_match( '/\bheight=["\']?(\d+(?:%|px)?)["\']?/i', $html, $m ) ) {
$height = $m[1];
} elseif ( preg_match( '/height\s*:\s*(\d+)px/i', $html, $m ) ) {
$height = $m[1];
}
// Normalise "450px" → "450" so it can be used as a numeric attribute.
$width = preg_replace( '/px$/', '', $width );
$height = preg_replace( '/px$/', '', $height );
return [ 'src' => $src, 'width' => $width, 'height' => $height ];
}
}

View File

@@ -0,0 +1,746 @@
<?php
defined( 'ABSPATH' ) || exit;
class CB_Settings {
public static function init(): void {
add_action( 'admin_menu', [ __CLASS__, 'add_menu' ] );
add_action( 'admin_init', [ __CLASS__, 'register_settings' ] );
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_admin_assets' ] );
add_action( 'admin_post_cb_save_services', [ __CLASS__, 'save_services' ] );
add_action( 'wp_ajax_cb_scan', [ __CLASS__, 'ajax_scan' ] );
}
public static function add_menu(): void {
add_options_page(
__( 'GDPR Content Blocker', 'gdpr-content-blocker' ),
__( 'GDPR Content Blocker', 'gdpr-content-blocker' ),
'manage_options',
'gdpr-content-blocker',
[ __CLASS__, 'render_page' ]
);
}
public static function register_settings(): void {
register_setting(
'cb_style_group',
CB_STYLE_OPTION,
[ 'sanitize_callback' => [ __CLASS__, 'sanitize_style' ] ]
);
}
public static function sanitize_style( mixed $input ): array {
$defaults = self::get_style_defaults();
if ( ! is_array( $input ) ) {
return $defaults;
}
$clean = [];
$color_keys = [
'text_color',
'bg_color',
'button_bg',
'button_text',
'button_hover_bg',
'button_hover_text',
];
foreach ( $color_keys as $key ) {
$val = isset( $input[ $key ] ) ? sanitize_text_field( $input[ $key ] ) : '';
$clean[ $key ] = self::sanitize_hex_color( $val ) ?? $defaults[ $key ];
}
$clean['custom_css'] = isset( $input['custom_css'] )
? wp_strip_all_tags( $input['custom_css'] )
: '';
return $clean;
}
private static function sanitize_hex_color( string $color ): ?string {
if ( preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ) {
return $color;
}
return null;
}
public static function get_style_defaults(): array {
return [
'text_color' => '#ffffff',
'bg_color' => '#111111',
'button_bg' => '#2043B7',
'button_text' => '#ffffff',
'button_hover_bg' => '#1a369a',
'button_hover_text' => '#ffffff',
'custom_css' => '',
];
}
public static function get_style(): array {
$saved = get_option( CB_STYLE_OPTION, [] );
return wp_parse_args( is_array( $saved ) ? $saved : [], self::get_style_defaults() );
}
/** Returns all services from the option, always as a list. */
public static function get_services(): array {
$raw = get_option( CB_OPTION, [] );
return is_array( $raw ) ? array_values( $raw ) : [];
}
/** Returns a single service by id, or null. */
public static function get_service( string $id ): ?array {
foreach ( self::get_services() as $svc ) {
if ( isset( $svc['id'] ) && $svc['id'] === $id ) {
return $svc;
}
}
return null;
}
public static function save_services(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_save_services', 'cb_nonce' );
$raw_services = isset( $_POST['cb_services'] ) && is_array( $_POST['cb_services'] )
? $_POST['cb_services']
: [];
$services = [];
foreach ( $raw_services as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$id = sanitize_key( $item['id'] ?? '' );
if ( $id === '' ) {
continue;
}
$services[] = [
'id' => $id,
'name' => sanitize_text_field( $item['name'] ?? '' ),
'enabled' => ! empty( $item['enabled'] ),
'match_pattern' => sanitize_text_field( $item['match_pattern'] ?? '' ),
'recipient' => sanitize_text_field( $item['recipient'] ?? '' ),
'third_country' => ! empty( $item['third_country'] ),
'sets_cookie' => ! empty( $item['sets_cookie'] ),
'loads_script' => ! empty( $item['loads_script'] ),
'purpose' => sanitize_textarea_field( $item['purpose'] ?? '' ),
'privacy_url' => esc_url_raw( $item['privacy_url'] ?? '' ),
'placeholder_text' => sanitize_textarea_field( $item['placeholder_text'] ?? '' ),
];
}
update_option( CB_OPTION, $services );
wp_safe_redirect( admin_url( 'options-general.php?page=gdpr-content-blocker&cb_saved=1' ) );
exit;
}
public static function enqueue_admin_assets( string $hook ): void {
if ( $hook !== 'settings_page_gdpr-content-blocker' ) {
return;
}
wp_enqueue_style( 'wp-color-picker' );
wp_enqueue_script(
'cb-admin',
CB_URL . 'assets/admin.js',
[ 'wp-color-picker', 'jquery' ],
CB_VERSION,
true
);
wp_localize_script( 'cb-admin', 'cbAdmin', [
'confirmRemove' => __( 'Dienst wirklich entfernen?', 'gdpr-content-blocker' ),
'presets' => self::get_presets(),
'newServiceLbl' => __( 'Neuer Dienst', 'gdpr-content-blocker' ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'scanNonce' => wp_create_nonce( 'cb_scan' ),
'i18n' => [
'scanning' => __( 'Scanne Webseite …', 'gdpr-content-blocker' ),
'scanError' => __( 'Scan fehlgeschlagen:', 'gdpr-content-blocker' ),
'noFindings' => __( 'Keine externen Einbindungen gefunden.', 'gdpr-content-blocker' ),
'host' => __( 'Anbieter / Host', 'gdpr-content-blocker' ),
'type' => __( 'Typ', 'gdpr-content-blocker' ),
'count' => __( 'Anzahl', 'gdpr-content-blocker' ),
'example' => __( 'Beispiel-URL', 'gdpr-content-blocker' ),
'status' => __( 'Status', 'gdpr-content-blocker' ),
'action' => __( 'Aktion', 'gdpr-content-blocker' ),
'covered' => __( 'abgedeckt', 'gdpr-content-blocker' ),
'thirdParty' => __( 'Drittanbieter', 'gdpr-content-blocker' ),
'firstParty' => __( 'eigene Domain', 'gdpr-content-blocker' ),
'addService' => __( 'Als Dienst übernehmen', 'gdpr-content-blocker' ),
'scannedPages'=> __( 'Gescannte Seiten:', 'gdpr-content-blocker' ),
'foundOn' => __( 'Gefunden auf', 'gdpr-content-blocker' ),
],
] );
}
/**
* Ready-made templates for the most common third-party iframes.
* Everything stays editable after insertion.
*/
public static function get_presets(): array {
return [
'google-maps' => [
'id' => 'google-maps',
'name' => 'Google Maps',
'match_pattern' => 'google.com/maps',
'recipient' => 'Google Ireland Ltd., Irland / Google LLC, USA',
'third_country' => true,
'sets_cookie' => true,
'loads_script' => true,
'purpose' => __( 'Darstellung interaktiver Karten und Standortinformationen.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://policies.google.com/privacy',
],
'youtube' => [
'id' => 'youtube',
'name' => 'YouTube',
'match_pattern' => 'youtube',
'recipient' => 'Google Ireland Ltd., Irland / Google LLC, USA',
'third_country' => true,
'sets_cookie' => true,
'loads_script' => true,
'purpose' => __( 'Einbettung und Wiedergabe von Videos.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://policies.google.com/privacy',
],
'openstreetmap' => [
'id' => 'openstreetmap',
'name' => 'OpenStreetMap',
'match_pattern' => 'openstreetmap.org',
'recipient' => 'OpenStreetMap Foundation, Großbritannien',
'third_country' => true,
'sets_cookie' => false,
'loads_script' => true,
'purpose' => __( 'Darstellung interaktiver Karten.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://wiki.osmfoundation.org/wiki/Privacy_Policy',
],
'vimeo' => [
'id' => 'vimeo',
'name' => 'Vimeo',
'match_pattern' => 'player.vimeo.com',
'recipient' => 'Vimeo LLC, USA',
'third_country' => true,
'sets_cookie' => true,
'loads_script' => true,
'purpose' => __( 'Einbettung und Wiedergabe von Videos.', 'gdpr-content-blocker' ),
'privacy_url' => 'https://vimeo.com/privacy',
],
];
}
public static function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$services = self::get_services();
$style = self::get_style();
$saved = isset( $_GET['cb_saved'] ) && $_GET['cb_saved'] === '1';
$settings_saved = isset( $_GET['settings-updated'] ) && $_GET['settings-updated'] === 'true';
?>
<div class="wrap cb-admin-wrap">
<h1><?php esc_html_e( 'GDPR Content Blocker', 'gdpr-content-blocker' ); ?></h1>
<?php if ( $saved || $settings_saved ) : ?>
<div class="notice notice-success is-dismissible">
<p><?php esc_html_e( 'Einstellungen gespeichert.', 'gdpr-content-blocker' ); ?></p>
</div>
<?php endif; ?>
<nav class="nav-tab-wrapper">
<a href="#cb-tab-services" class="nav-tab nav-tab-active" data-cb-tab="services">
<?php esc_html_e( 'Dienste', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-scan" class="nav-tab" data-cb-tab="scan">
<?php esc_html_e( 'Scan', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-style" class="nav-tab" data-cb-tab="style">
<?php esc_html_e( 'Darstellung', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-license" class="nav-tab" data-cb-tab="license">
<?php esc_html_e( 'Lizenz', 'gdpr-content-blocker' ); ?>
</a>
<a href="#cb-tab-about" class="nav-tab" data-cb-tab="about">
<?php esc_html_e( 'Über das Plugin', 'gdpr-content-blocker' ); ?>
</a>
</nav>
<!-- Services Tab -->
<div id="cb-tab-services" class="cb-tab-content">
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="cb_save_services">
<?php wp_nonce_field( 'cb_save_services', 'cb_nonce' ); ?>
<div id="cb-services-list">
<?php foreach ( $services as $i => $svc ) : ?>
<?php self::render_service_row( $i, $svc ); ?>
<?php endforeach; ?>
</div>
<div class="cb-add-row">
<button type="button" id="cb-add-service" class="button">
<?php esc_html_e( '+ Leeren Dienst hinzufügen', 'gdpr-content-blocker' ); ?>
</button>
<select id="cb-preset-select">
<option value=""><?php esc_html_e( '— Vorlage einfügen —', 'gdpr-content-blocker' ); ?></option>
<?php foreach ( self::get_presets() as $key => $preset ) : ?>
<option value="<?php echo esc_attr( $key ); ?>">
<?php echo esc_html( $preset['name'] ); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php submit_button( __( 'Dienste speichern', 'gdpr-content-blocker' ) ); ?>
</form>
<!-- Hidden template for JS -->
<template id="cb-service-template">
<?php self::render_service_row( '__INDEX__', [] ); ?>
</template>
</div>
<!-- Scan Tab -->
<div id="cb-tab-scan" class="cb-tab-content" style="display:none;">
<p class="description" style="max-width:760px;">
<?php esc_html_e( 'Der Lizenzserver besucht Ihre Webseite und listet alle eingebundenen Drittanbieter-Ressourcen (iframes, Skripte, Schriften, Bilder, …) auf. So sehen Sie auf einen Blick, welche externen Dienste Sie blockieren sollten. Erfordert eine aktive Lizenz.', 'gdpr-content-blocker' ); ?>
</p>
<p>
<button type="button" id="cb-scan-btn" class="button button-primary">
<?php esc_html_e( 'Webseite scannen', 'gdpr-content-blocker' ); ?>
</button>
<span id="cb-scan-status" style="margin-left:10px;"></span>
</p>
<div id="cb-scan-results"></div>
</div>
<!-- Style Tab -->
<div id="cb-tab-style" class="cb-tab-content" style="display:none;">
<form method="post" action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>">
<?php
settings_fields( 'cb_style_group' );
self::render_style_fields( $style );
submit_button( __( 'Darstellung speichern', 'gdpr-content-blocker' ) );
?>
</form>
<?php self::render_usage_help(); ?>
</div>
<!-- License Tab -->
<div id="cb-tab-license" class="cb-tab-content" style="display:none;">
<?php CB_License::render_tab(); ?>
</div>
<!-- About Tab -->
<div id="cb-tab-about" class="cb-tab-content" style="display:none;">
<?php self::render_about(); ?>
</div>
</div>
<style>
.cb-admin-wrap .cb-service-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px 16px;
margin: 8px 0;
}
.cb-admin-wrap .cb-service-head {
display: flex;
align-items: center;
gap: 10px;
}
.cb-admin-wrap .cb-service-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 4px 6px;
color: #50575e;
}
.cb-admin-wrap .cb-service-title {
flex: 1;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.cb-admin-wrap .cb-service-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #eee;
}
/* toggle switch */
.cb-admin-wrap .cb-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex: 0 0 auto;
}
.cb-admin-wrap .cb-switch input {
opacity: 0;
width: 0;
height: 0;
}
.cb-admin-wrap .cb-switch__slider {
position: absolute;
inset: 0;
background: #c3c4c7;
border-radius: 22px;
transition: background 0.2s;
}
.cb-admin-wrap .cb-switch__slider::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
top: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.cb-admin-wrap .cb-switch input:checked + .cb-switch__slider {
background: #2043B7;
}
.cb-admin-wrap .cb-switch input:checked + .cb-switch__slider::before {
transform: translateX(18px);
}
.cb-admin-wrap .cb-field label {
display: block;
font-weight: 600;
margin-bottom: 4px;
}
.cb-admin-wrap .cb-field input[type="text"],
.cb-admin-wrap .cb-field input[type="url"],
.cb-admin-wrap .cb-field textarea {
width: 100%;
}
.cb-admin-wrap .cb-field-full {
grid-column: 1 / -1;
}
.cb-admin-wrap .cb-checks {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.cb-admin-wrap .cb-add-row {
display: flex;
gap: 10px;
align-items: center;
margin: 12px 0;
}
.cb-admin-wrap .cb-add-row select {
max-width: 240px;
}
</style>
<?php
}
public static function render_service_row( int|string $index, array $svc ): void {
$f = fn( string $key, string $default = '' ) => esc_attr( $svc[ $key ] ?? $default );
$b = fn( string $key ) => ! empty( $svc[ $key ] );
$idx = esc_attr( (string) $index );
$name = $svc['name'] ?? '';
$id = $svc['id'] ?? '';
$enabled = $svc['enabled'] ?? true; // default ON (new + legacy services)
// Header label: "interner-name — Anbietername"
$headname = trim( ( $id !== '' ? $id : __( 'neu', 'gdpr-content-blocker' ) ) . ( $name !== '' ? ' — ' . $name : '' ) );
?>
<div class="cb-service-box" data-cb-index="<?php echo $idx; ?>">
<div class="cb-service-head">
<button type="button" class="cb-service-toggle" aria-expanded="false" title="<?php esc_attr_e( 'Details anzeigen/ausblenden', 'gdpr-content-blocker' ); ?>">▸</button>
<span class="cb-service-title"><?php echo esc_html( $headname ); ?></span>
<label class="cb-switch" title="<?php esc_attr_e( 'Blocker aktiv/inaktiv', 'gdpr-content-blocker' ); ?>">
<input type="checkbox" class="cb-input-enabled" name="cb_services[<?php echo $idx; ?>][enabled]"
value="1" <?php checked( $enabled ); ?>>
<span class="cb-switch__slider"></span>
</label>
<button type="button" class="button-link-delete cb-remove-service">
<?php esc_html_e( 'Entfernen', 'gdpr-content-blocker' ); ?>
</button>
</div>
<div class="cb-service-grid" style="display:none;">
<div class="cb-field">
<label><?php esc_html_e( 'Interner Name (Slug)', 'gdpr-content-blocker' ); ?> *</label>
<input type="text" name="cb_services[<?php echo $idx; ?>][id]"
value="<?php echo $f( 'id' ); ?>" required
placeholder="google-maps" pattern="[a-z0-9\-]+" class="cb-input-id">
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Anbietername', 'gdpr-content-blocker' ); ?> *</label>
<input type="text" name="cb_services[<?php echo $idx; ?>][name]"
value="<?php echo $f( 'name' ); ?>" required class="cb-input-name">
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Erkennungsmuster (Domain/Pfad)', 'gdpr-content-blocker' ); ?></label>
<input type="text" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][match_pattern]"
value="<?php echo $f( 'match_pattern' ); ?>"
placeholder="google.com/maps">
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Empfänger (inkl. Land)', 'gdpr-content-blocker' ); ?> *</label>
<input type="text" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][recipient]"
value="<?php echo $f( 'recipient' ); ?>"
placeholder="Google Ireland Ltd. / USA" required>
</div>
<div class="cb-field">
<label><?php esc_html_e( 'Datenschutz-URL des Anbieters', 'gdpr-content-blocker' ); ?></label>
<input type="url" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][privacy_url]"
value="<?php echo esc_attr( esc_url( $svc['privacy_url'] ?? '' ) ); ?>"
placeholder="https://policies.google.com/privacy">
</div>
<div class="cb-field cb-field-full">
<label><?php esc_html_e( 'Verarbeitungszweck', 'gdpr-content-blocker' ); ?> *</label>
<textarea name="cb_services[<?php echo esc_attr( (string) $index ); ?>][purpose]"
rows="2" required><?php echo esc_textarea( $svc['purpose'] ?? '' ); ?></textarea>
</div>
<div class="cb-field cb-field-full">
<label><?php esc_html_e( 'Individueller Platzhaltertext (leer = Standard)', 'gdpr-content-blocker' ); ?></label>
<textarea name="cb_services[<?php echo esc_attr( (string) $index ); ?>][placeholder_text]"
rows="2"><?php echo esc_textarea( $svc['placeholder_text'] ?? '' ); ?></textarea>
</div>
<div class="cb-field cb-field-full cb-checks">
<label>
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][third_country]"
value="1" <?php checked( $b( 'third_country' ) ); ?>>
<?php esc_html_e( 'Datenübermittlung in Drittland (außerhalb EU/EWR)', 'gdpr-content-blocker' ); ?>
</label>
<label>
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][sets_cookie]"
value="1" <?php checked( $b( 'sets_cookie' ) ); ?>>
<?php esc_html_e( 'Setzt Cookies', 'gdpr-content-blocker' ); ?>
</label>
<label>
<input type="checkbox" name="cb_services[<?php echo esc_attr( (string) $index ); ?>][loads_script]"
value="1" <?php checked( $b( 'loads_script' ) ); ?>>
<?php esc_html_e( 'Lädt externe Skripte', 'gdpr-content-blocker' ); ?>
</label>
</div>
</div>
</div>
<?php
}
private static function render_style_fields( array $style ): void {
$fields = [
'text_color' => __( 'Textfarbe Platzhalter', 'gdpr-content-blocker' ),
'bg_color' => __( 'Hintergrundfarbe Platzhalter', 'gdpr-content-blocker' ),
'button_bg' => __( 'Button: Hintergrundfarbe', 'gdpr-content-blocker' ),
'button_text' => __( 'Button: Textfarbe', 'gdpr-content-blocker' ),
'button_hover_bg' => __( 'Button Hover: Hintergrundfarbe', 'gdpr-content-blocker' ),
'button_hover_text' => __( 'Button Hover: Textfarbe', 'gdpr-content-blocker' ),
];
echo '<table class="form-table" role="presentation"><tbody>';
foreach ( $fields as $key => $label ) {
$val = esc_attr( $style[ $key ] );
echo '<tr><th scope="row"><label>' . esc_html( $label ) . '</label></th>';
echo '<td><input type="text" class="cb-color-picker" name="' . esc_attr( CB_STYLE_OPTION . '[' . $key . ']' ) . '" value="' . $val . '" data-default-color="' . $val . '"></td></tr>';
}
echo '<tr><th scope="row"><label>' . esc_html__( 'Custom CSS', 'gdpr-content-blocker' ) . '</label></th>';
echo '<td><textarea name="' . esc_attr( CB_STYLE_OPTION . '[custom_css]' ) . '" rows="8" class="large-text code">' . esc_textarea( $style['custom_css'] ) . '</textarea>';
echo '<p class="description">' . esc_html__( 'Wird nach den CSS-Variablen eingebunden und kann diese überschreiben. Tipp: denselben Präfix verwenden, z. B. .cb-blocker .cb-blocker__button { … }', 'gdpr-content-blocker' ) . '</p></td></tr>';
echo '</tbody></table>';
}
/**
* Help box explaining shortcodes, revoke, auto-detection and CSS classes.
* Shown below "Darstellung speichern".
*/
private static function render_usage_help(): void {
$code = static fn( string $s ): string => '<code>' . esc_html( $s ) . '</code>';
?>
<hr style="margin:28px 0 18px;">
<h2><?php esc_html_e( 'Funktionen & Shortcodes', 'gdpr-content-blocker' ); ?></h2>
<table class="widefat striped" style="max-width:900px;">
<tbody>
<tr>
<td style="width:280px;vertical-align:top;"><strong><?php esc_html_e( 'Widerruf-Button (Pflicht in der Datenschutzerklärung)', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker_revoke]' ); ?></td>
<td><?php esc_html_e( 'Rendert einen gut sichtbaren Link, der die Einwilligung für externe Einbettungen widerruft und die Seite neu lädt (Art. 7 Abs. 3 DSGVO). Betrifft NICHT die Cookie-Einwilligung eines separaten Cookie-Plugins. Optionen: text="…", style="link|button", note="yes|no". In die Datenschutzerklärung einfügen.', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Inhalt manuell blockieren', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker id="google-maps"]…iframe…[/content_blocker]' ); ?></td>
<td><?php esc_html_e( 'Umschließt ein iframe und ersetzt es durch den Platzhalter des angegebenen Dienstes. Verwenden Sie den internen Namen aus dem Tab „Dienste". Ideal für Inhalte, die nicht automatisch erkannt werden (z. B. per JavaScript nachgeladene iframes).', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Dienste-Übersicht (für die Datenschutzerklärung)', 'gdpr-content-blocker' ); ?></strong><br>
<?php echo $code( '[content_blocker_services]' ); ?></td>
<td><?php esc_html_e( 'Listet alle konfigurierten Dienste mit Empfänger, Drittland-Hinweis, Zweck und Datenschutz-Link auf. Ideal zum Einbinden in die Datenschutzerklärung (Art. 13 DSGVO).', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Automatische Erkennung', 'gdpr-content-blocker' ); ?></strong></td>
<td><?php esc_html_e( 'iframes im Server-HTML, deren URL auf das „Erkennungsmuster" eines Dienstes passt, werden automatisch blockiert. Greift nur bei statischem HTML für JS-iframes den Shortcode oben nutzen.', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'Webseiten-Scan', 'gdpr-content-blocker' ); ?></strong></td>
<td><?php esc_html_e( 'Im Tab „Scan" listet der Lizenzserver alle eingebundenen Drittanbieter auf. Erfordert eine aktive Lizenz.', 'gdpr-content-blocker' ); ?></td>
</tr>
<tr>
<td style="vertical-align:top;"><strong><?php esc_html_e( 'CSS-Klassen', 'gdpr-content-blocker' ); ?></strong></td>
<td>
<?php echo $code( '.cb-blocker' ); ?>,
<?php echo $code( '.cb-blocker__text' ); ?>,
<?php echo $code( '.cb-blocker__recipient' ); ?>,
<?php echo $code( '.cb-blocker__purpose' ); ?>,
<?php echo $code( '.cb-blocker__privacy-link' ); ?>,
<?php echo $code( '.cb-blocker__button' ); ?>,
<?php echo $code( '.cb-revoke-btn' ); ?>
<p class="description" style="margin-top:6px;"><?php esc_html_e( 'Eigene Regeln im Custom-CSS mit Präfix .cb-blocker schreiben, damit sie das Theme überschreiben.', 'gdpr-content-blocker' ); ?></p>
</td>
</tr>
</tbody>
</table>
<?php
}
/** "Über das Plugin" tab: description + legal links. */
private static function render_about(): void {
?>
<div style="max-width:760px;">
<h2>GDPR Content Blocker</h2>
<p class="description" style="font-size:13px;">
<?php
printf(
/* translators: %s: version */
esc_html__( 'Version %s', 'gdpr-content-blocker' ),
esc_html( CB_VERSION )
);
?>
</p>
<p>
<?php esc_html_e( 'GDPR Content Blocker lädt externe Einbindungen (z. B. Google Maps, YouTube, OpenStreetMap, Vimeo) erst nach aktiver Einwilligung der Besucher. Vor dem Klick wird keine Verbindung zum Drittanbieter aufgebaut es werden also keine personenbezogenen Daten (z. B. die IP-Adresse) übertragen. So lassen sich externe Inhalte DSGVO-konform einbinden, ohne ein schweres Consent-Tool.', 'gdpr-content-blocker' ); ?>
</p>
<h3><?php esc_html_e( 'Funktionen', 'gdpr-content-blocker' ); ?></h3>
<ul style="list-style:disc;padding-left:20px;">
<li><?php esc_html_e( 'Echtes Click-to-Load (kein vorab geladenes iframe)', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Granulare Einwilligung pro Dienst', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Art.-13-konformer Platzhalter mit Empfänger, Zweck, Drittland-Hinweis und Datenschutz-Link', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Einfacher Widerruf per Shortcode (Art. 7 Abs. 3 DSGVO)', 'gdpr-content-blocker' ); ?></li>
<li><?php esc_html_e( 'Automatische Erkennung gängiger Anbieter + manueller Shortcode', 'gdpr-content-blocker' ); ?></li>
</ul>
<h3><?php esc_html_e( 'Rechtliches', 'gdpr-content-blocker' ); ?></h3>
<p>
<a href="https://lucas-orth.de/impressum" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Impressum', 'gdpr-content-blocker' ); ?></a><br>
<a href="https://lucas-orth.de/datenschutz" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Datenschutzerklärung', 'gdpr-content-blocker' ); ?></a>
</p>
<p class="description">
<?php esc_html_e( 'Hinweis: Dieses Plugin ist ein technisches Hilfsmittel und ersetzt keine Rechtsberatung. Für die rechtskonforme Konfiguration (Empfänger, Zwecke, Datenschutzerklärung) ist der Seitenbetreiber verantwortlich.', 'gdpr-content-blocker' ); ?>
</p>
<p style="margin-top:20px;">
<?php
printf(
/* translators: %s: author link */
esc_html__( 'Entwickelt von %s', 'gdpr-content-blocker' ),
'<a href="https://lucas-orth.de" target="_blank" rel="noopener noreferrer">Lucas Orth</a>'
);
?>
</p>
</div>
<?php
}
/* ───────────────────────── Scan (AJAX) ───────────────────────── */
/**
* Build the list of URLs to scan: the home page plus a few published
* pages/posts, so the scan covers more than just the front page.
*/
private static function scan_urls(): array {
$urls = [ home_url( '/' ) ];
$posts = get_posts( [
'post_type' => [ 'page', 'post' ],
'post_status' => 'publish',
'numberposts' => 4,
'orderby' => 'comment_count', // roughly "most visited"
'order' => 'DESC',
'fields' => 'ids',
'no_found_rows' => true,
] );
foreach ( $posts as $pid ) {
$link = get_permalink( $pid );
if ( $link ) {
$urls[] = $link;
}
}
return array_values( array_unique( $urls ) );
}
public static function ajax_scan(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Keine Berechtigung.', 'gdpr-content-blocker' ) ], 403 );
}
check_ajax_referer( 'cb_scan', 'nonce' );
$lic = CB_License::get_license();
if ( ( $lic['status'] ?? '' ) !== 'active' ) {
wp_send_json_error( [ 'message' => __( 'Bitte zuerst eine Lizenz aktivieren (Tab „Lizenz").', 'gdpr-content-blocker' ) ] );
}
$response = wp_remote_post( CB_License::api_url() . '/api/v1/scan', [
'timeout' => 45,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode( [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => CB_License::domain(),
'urls' => self::scan_urls(),
] ),
] );
if ( is_wp_error( $response ) ) {
wp_send_json_error( [ 'message' => $response->get_error_message() ] );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) || empty( $data['ok'] ) ) {
$msg = is_array( $data ) && ! empty( $data['error'] )
? $data['error']
: __( 'Unerwartete Antwort vom Lizenzserver.', 'gdpr-content-blocker' );
wp_send_json_error( [ 'message' => $msg ] );
}
// Annotate each finding: is it already covered by a configured service?
$services = self::get_services();
$findings = is_array( $data['findings'] ?? null ) ? $data['findings'] : [];
foreach ( $findings as &$f ) {
$f['covered'] = self::is_covered( $f, $services );
}
unset( $f );
wp_send_json_success( [
'findings' => $findings,
'scanned' => $data['scanned'] ?? [],
] );
}
/** True if any configured service's match_pattern matches this finding. */
private static function is_covered( array $finding, array $services ): bool {
$haystacks = array_merge(
[ (string) ( $finding['host'] ?? '' ) ],
array_map( 'strval', (array) ( $finding['sample_urls'] ?? [] ) )
);
foreach ( $services as $svc ) {
$pattern = $svc['match_pattern'] ?? '';
if ( $pattern === '' ) {
continue;
}
foreach ( $haystacks as $h ) {
if ( $h !== '' && str_contains( $h, $pattern ) ) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,139 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Self-hosted update delivery. Asks the license backend whether a newer version
* exists and, if so, injects it into WordPress' update flow. The package is only
* served to licensed, activated sites (the backend gates the download).
*
* Without an active license nothing is offered (CB_License::gate_updates() is the
* additional safety net that strips any stray update entry).
*/
class CB_Updater {
const TRANSIENT = 'cb_update_info';
const CACHE_TTL = 6 * HOUR_IN_SECONDS;
public static function init(): void {
add_filter( 'pre_set_site_transient_update_plugins', [ __CLASS__, 'inject' ] );
add_filter( 'plugins_api', [ __CLASS__, 'plugin_info' ], 20, 3 );
add_action( 'upgrader_process_complete', [ __CLASS__, 'flush_cache' ], 10, 0 );
}
private static function basename(): string {
return plugin_basename( CB_FILE );
}
private static function slug(): string {
return dirname( self::basename() ); // e.g. "gdpr-content-blocker"
}
public static function flush_cache(): void {
delete_transient( self::TRANSIENT );
}
/**
* Fetch update info from the backend (cached). Returns the decoded array
* or null when unlicensed / on error.
*/
private static function fetch_info(): ?array {
if ( ! CB_License::is_active() ) {
return null;
}
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
$lic = CB_License::get_license();
$response = wp_remote_post( CB_License::api_url() . '/api/v1/update', [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ],
'body' => wp_json_encode( [
'key' => $lic['key'],
'product' => CB_PRODUCT_SLUG,
'domain' => CB_License::domain(),
'version' => CB_VERSION,
] ),
] );
if ( is_wp_error( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) || empty( $data['ok'] ) ) {
return null;
}
set_transient( self::TRANSIENT, $data, self::CACHE_TTL );
return $data;
}
/** Inject the update into the plugins update transient. */
public static function inject( mixed $transient ): mixed {
if ( ! is_object( $transient ) ) {
return $transient;
}
$info = self::fetch_info();
if ( ! $info || empty( $info['update_available'] ) || empty( $info['package'] ) ) {
return $transient;
}
// Only offer if the backend's version is actually newer than ours.
if ( version_compare( $info['version'] ?? '0', CB_VERSION, '<=' ) ) {
return $transient;
}
$item = [
'slug' => self::slug(),
'plugin' => self::basename(),
'new_version' => (string) $info['version'],
'package' => esc_url_raw( $info['package'] ),
'url' => 'https://lucas-orth.de',
'tested' => (string) ( $info['tested'] ?? '' ),
'requires' => (string) ( $info['requires'] ?? '' ),
'requires_php'=> (string) ( $info['requires_php'] ?? '' ),
'icons' => [],
'banners' => [],
];
$transient->response[ self::basename() ] = (object) $item;
return $transient;
}
/**
* Provide the "View version details" popup content.
*/
public static function plugin_info( mixed $result, string $action, object $args ): mixed {
if ( $action !== 'plugin_information' ) {
return $result;
}
if ( empty( $args->slug ) || $args->slug !== self::slug() ) {
return $result;
}
$info = self::fetch_info();
if ( ! $info ) {
return $result;
}
return (object) [
'name' => 'GDPR Content Blocker',
'slug' => self::slug(),
'version' => (string) ( $info['version'] ?? CB_VERSION ),
'author' => '<a href="https://lucas-orth.de">Lucas Orth</a>',
'homepage' => 'https://lucas-orth.de',
'requires' => (string) ( $info['requires'] ?? '' ),
'tested' => (string) ( $info['tested'] ?? '' ),
'requires_php' => (string) ( $info['requires_php'] ?? '' ),
'download_link' => esc_url_raw( $info['package'] ?? '' ),
'sections' => [
'changelog' => wp_kses_post( $info['changelog'] ?? '' ),
],
];
}
}