'impressum', 'name' => 'Impressum', 'page_kw' => [ 'impressum', 'imprint' ], 'severity' => 'critical', 'explanation' => 'Kein Impressum als Seite gefunden. Nach DDG §5 ist ein vollständiges Impressum für alle geschäftlichen Websites Pflicht.', 'quickFix' => 'WordPress > Seiten > Neue Seite "Impressum" anlegen und im Footer-Menü verlinken.', ], [ 'id' => 'datenschutz', 'name' => 'Datenschutzerklärung', 'page_kw' => [ 'datenschutz', 'privacy', 'datenschutzerklaerung' ], 'wp_privacy' => true, 'severity' => 'critical', 'explanation' => 'Keine Datenschutzerklärung gefunden. Nach DSGVO Art. 13 ist diese Pflicht für jede Website.', 'quickFix' => 'WordPress > Einstellungen > Datenschutz > Datenschutz-Seite auswählen oder neue Seite erstellen.', ], [ 'id' => 'agb', 'name' => 'AGB', 'page_kw' => [ 'agb', 'allgemeine-geschaeftsbedingungen', 'terms' ], 'wc_page' => 'terms', 'shop_only' => true, 'severity' => 'warning', 'explanation' => 'Keine AGB-Seite gefunden. Für deutsche Onlineshops dringend empfohlen.', 'quickFix' => 'WordPress > Seiten > Neue Seite "AGB" erstellen. Bei WooCommerce zusaetzlich unter WooCommerce > Einstellungen > Erweitert > Seiten-Einrichtung als AGB-Seite zuweisen.', ], [ 'id' => 'widerruf', 'name' => 'Widerrufsbelehrung', 'page_kw' => [ 'widerruf', 'widerrufsbelehrung', 'revocation', 'cancellation' ], 'shop_only' => true, 'severity' => 'critical', 'explanation' => 'Keine Widerrufsbelehrung gefunden. Nach §355 BGB müssen Verbraucher über ihr Widerrufsrecht belehrt werden.', 'quickFix' => 'WordPress > Seiten > Neue Seite "Widerrufsbelehrung" erstellen und im Footer-Menü verlinken.', ], [ 'id' => 'versand', 'name' => 'Versandinformationen', 'page_kw' => [ 'versand', 'lieferung', 'shipping', 'versandkosten' ], 'shop_only' => true, 'severity' => 'warning', 'explanation' => 'Keine Versandinformationen gefunden. Lieferzeiten und Versandkosten müssen transparent angegeben werden (§ 312d BGB).', 'quickFix' => 'WordPress > Seiten > Neue Seite "Versand & Lieferung" erstellen und im Footer verlinken.', ], ]; public function handle_quick_scan( WP_REST_Request $request ) { $findings = []; $passed = []; $all_pages = get_pages( [ 'post_status' => 'publish' ] ); $page_slugs = array_map( function ( $p ) { return strtolower( $p->post_name ); }, $all_pages ); $page_titles = array_map( function ( $p ) { return strtolower( $p->post_title ); }, $all_pages ); $privacy_page_id = (int) get_option( 'wp_page_for_privacy_policy', 0 ); $wc_terms_page = (int) get_option( 'woocommerce_terms_page_id', 0 ); $topic_exists = []; $has_shop = abmahn_shield_has_woocommerce(); foreach ( $this->legal_checks as $check ) { // AGB, Widerrufsbelehrung und Versandangaben sind Pflichten aus dem // Fernabsatz-/Shop-Recht. Auf Seiten ohne Shop wuerden sie einen // Fehlalarm erzeugen (z.B. bei Vereinen oder Coaching-Seiten), // deshalb nur pruefen, wenn WooCommerce aktiv ist. if ( ! empty( $check['shop_only'] ) && ! $has_shop ) { continue; } $found = false; if ( ! empty( $check['wp_privacy'] ) && $privacy_page_id > 0 ) { $page = get_post( $privacy_page_id ); if ( $page && 'publish' === $page->post_status && strlen( $page->post_content ) > 100 ) { $found = true; } } if ( ! $found && ! empty( $check['wc_page'] ) && 'terms' === $check['wc_page'] && $wc_terms_page > 0 ) { $page = get_post( $wc_terms_page ); if ( $page && 'publish' === $page->post_status && strlen( $page->post_content ) > 50 ) { $found = true; } } if ( ! $found ) { foreach ( $check['page_kw'] as $kw ) { foreach ( $page_slugs as $i => $slug ) { if ( false !== strpos( $slug, $kw ) || false !== strpos( $page_titles[ $i ], $kw ) ) { $found = true; break 2; } } } } $topic_exists[ $check['id'] ] = $found; if ( $found ) { $passed[] = [ 'id' => 'pass-' . $check['id'], 'name' => $check['name'] . ' vorhanden', ]; } else { $findings[] = [ 'id' => 'missing-' . $check['id'], 'name' => $check['name'] . ' fehlt', 'severity' => $check['severity'], 'category' => 'wordpress', 'explanation' => $check['explanation'], 'quickFix' => $check['quickFix'], 'source' => 'WordPress', ]; } } $html_findings = $this->check_storefront_html( $topic_exists ); $findings = array_merge( $findings, $html_findings['findings'] ); $passed = array_merge( $passed, $html_findings['passed'] ); $critical_count = count( array_filter( $findings, function ( $f ) { return 'critical' === $f['severity']; } ) ); $warning_count = count( array_filter( $findings, function ( $f ) { return 'warning' === $f['severity']; } ) ); $result = [ 'scanId' => 'wc-' . time(), 'url' => home_url(), 'findings' => $findings, 'passed' => $passed, 'riskCount' => $critical_count + $warning_count, 'criticalCount' => $critical_count, 'warningCount' => $warning_count, 'passedCount' => count( $passed ), 'scanType' => 'quick', 'shopChecks' => $has_shop, 'lastScanAt' => current_time( 'c' ), ]; update_option( 'abmahn_shield_last_scan', $result ); return new WP_REST_Response( $result, 200 ); } public function handle_deep_scan( WP_REST_Request $request ) { $site_key = $this->get_or_register_site_key(); if ( is_wp_error( $site_key ) ) { return $site_key; } $site_url = home_url(); $response = wp_remote_post( ABMAHN_SHIELD_API_BASE . '/api/wc/scan', [ 'timeout' => 120, 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'Abmahn-Shield-WooCommerce/' . ABMAHN_SHIELD_VERSION, ], 'body' => wp_json_encode( [ 'siteKey' => $site_key, 'url' => $site_url, 'ownerConfirmed' => true, ] ), ] ); if ( is_wp_error( $response ) ) { return new WP_Error( 'scan_failed', 'Verbindung zum Abmahn-Shield Server fehlgeschlagen: ' . $response->get_error_message(), [ 'status' => 502 ] ); } $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status_code >= 400 ) { return new WP_Error( 'scan_error', $body['error'] ?? 'Deep-Scan fehlgeschlagen (HTTP ' . $status_code . ')', [ 'status' => $status_code ] ); } $body['scanType'] = 'deep'; $body['lastScanAt'] = current_time( 'c' ); update_option( 'abmahn_shield_last_deep_scan', $body ); update_option( 'abmahn_shield_last_scan', $body ); return new WP_REST_Response( $body, 200 ); } public function handle_last_scan( WP_REST_Request $request ) { $scan = get_option( 'abmahn_shield_last_scan', null ); if ( ! $scan ) { return new WP_REST_Response( [ 'scan' => null ], 200 ); } return new WP_REST_Response( [ 'scan' => $scan ], 200 ); } public function get_site_key() { return get_option( 'abmahn_shield_site_key', '' ); } private function get_or_register_site_key() { $existing = get_option( 'abmahn_shield_site_key', '' ); if ( ! empty( $existing ) ) { return $existing; } $response = wp_remote_post( ABMAHN_SHIELD_API_BASE . '/api/wc/register', [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'Abmahn-Shield-WooCommerce/' . ABMAHN_SHIELD_VERSION, ], 'body' => wp_json_encode( [ 'siteUrl' => home_url(), 'email' => get_option( 'admin_email', '' ), ] ), ] ); if ( is_wp_error( $response ) ) { return new WP_Error( 'registration_failed', 'Verbindung zum Abmahn-Shield Server fehlgeschlagen: ' . $response->get_error_message(), [ 'status' => 502 ] ); } $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status_code >= 400 || empty( $body['siteKey'] ) ) { return new WP_Error( 'registration_error', $body['error'] ?? 'Registrierung fehlgeschlagen (HTTP ' . $status_code . ')', [ 'status' => $status_code ] ); } update_option( 'abmahn_shield_site_key', $body['siteKey'], false ); return $body['siteKey']; } /** * Prueft, ob im HTML ein ECHTER Link zu einem der Stichworte existiert. * * Frueher wurde nur per strpos nach dem Wort gesucht. Das deutsche * WordPress-Standardtheme liefert aber einen Navigationspunkt "Impressum" * mit href="#" aus — ein toter Platzhalter, der so faelschlich als * "verlinkt" durchging. Deshalb: nur Anker mit einem echten Ziel zaehlen. * * @param string $html Bereits kleingeschriebenes HTML. * @param array $keywords Stichworte, z.B. [ 'impressum', 'imprint' ]. * @return bool */ private function has_real_link( $html, $keywords ) { if ( ! preg_match_all( '/]*href\\s*=\\s*["\']([^"\']*)["\'][^>]*>(.*?)<\\/a>/is', $html, $matches, PREG_SET_ORDER ) ) { return false; } foreach ( $matches as $m ) { $href = trim( $m[1] ); $label = wp_strip_all_tags( $m[2] ); // Platzhalter und Nicht-Ziele verwerfen. if ( '' === $href || '#' === $href || 0 === strpos( $href, 'javascript:' ) ) { continue; } foreach ( $keywords as $kw ) { if ( false !== strpos( $href, $kw ) || false !== strpos( $label, $kw ) ) { return true; } } } return false; } private function check_storefront_html( $topic_exists ) { $findings = []; $passed = []; $response = wp_remote_get( home_url(), [ 'timeout' => 15, 'user-agent' => 'Abmahn-Shield-Scanner/1.0', ] ); if ( is_wp_error( $response ) ) { return [ 'findings' => $findings, 'passed' => $passed ]; } $html = wp_remote_retrieve_body( $response ); $html_lower = strtolower( $html ); // Google Fonts extern? if ( false !== strpos( $html_lower, 'fonts.googleapis.com' ) || false !== strpos( $html_lower, 'fonts.gstatic.com' ) ) { $findings[] = [ 'id' => 'html-google-fonts-extern', 'name' => 'Google Fonts werden extern geladen', 'severity' => 'critical', 'category' => 'html-check', 'explanation' => 'Dein Shop lädt Google Fonts von Google-Servern. Dabei wird die IP-Adresse deiner Besucher an Google (USA) übertragen. Seit dem BGH-Urteil 2022 ist dies ohne Einwilligung abmahnfähig (Streitwert: ca. 1.000 EUR).', 'quickFix' => 'Plugin "OMGF" oder "Local Google Fonts" installieren, um Google Fonts lokal einzubinden.', 'source' => 'HTML-Analyse', ]; } else { $passed[] = [ 'id' => 'pass-fonts', 'name' => 'Keine externen Google Fonts', ]; } // Tracker ohne Consent? $trackers = []; if ( false !== strpos( $html_lower, 'google-analytics.com' ) || false !== strpos( $html_lower, 'googletagmanager.com' ) || false !== strpos( $html_lower, 'gtag(' ) ) { $trackers[] = 'Google Analytics/GTM'; } if ( false !== strpos( $html_lower, 'facebook.com/tr' ) || false !== strpos( $html_lower, 'fbevents.js' ) || false !== strpos( $html_lower, 'connect.facebook.net' ) ) { $trackers[] = 'Facebook Pixel'; } if ( false !== strpos( $html_lower, 'tiktok.com/i18n/pixel' ) ) { $trackers[] = 'TikTok Pixel'; } if ( false !== strpos( $html_lower, 'hotjar.com' ) ) { $trackers[] = 'Hotjar'; } if ( false !== strpos( $html_lower, 'clarity.ms' ) ) { $trackers[] = 'Microsoft Clarity'; } if ( ! empty( $trackers ) ) { $has_consent = false !== strpos( $html_lower, 'cookiebot' ) || false !== strpos( $html_lower, 'onetrust' ) || false !== strpos( $html_lower, 'usercentrics' ) || false !== strpos( $html_lower, 'borlabs' ) || false !== strpos( $html_lower, 'klaro' ) || false !== strpos( $html_lower, 'tarteaucitron' ) || false !== strpos( $html_lower, 'complianz' ) || false !== strpos( $html_lower, 'real-cookie-banner' ) || ( false !== strpos( $html_lower, 'cookie' ) && false !== strpos( $html_lower, 'consent' ) ); if ( ! $has_consent ) { $findings[] = [ 'id' => 'html-tracker-no-consent', 'name' => 'Tracker ohne Cookie-Consent: ' . implode( ', ', $trackers ), 'severity' => 'critical', 'category' => 'html-check', 'explanation' => 'Dein Shop lädt ' . implode( ', ', $trackers ) . ' ohne erkennbaren Cookie-Consent-Banner. Nach TDDDG §25 und DSGVO ist das Setzen von Tracking-Cookies ohne Einwilligung abmahnfähig.', 'quickFix' => 'Cookie-Consent-Plugin installieren (z.B. "Complianz", "Real Cookie Banner" oder "Borlabs Cookie").', 'source' => 'HTML-Analyse', ]; } else { $passed[] = [ 'id' => 'pass-consent', 'name' => 'Cookie-Consent-Banner erkannt', ]; } } else { $passed[] = [ 'id' => 'pass-no-tracker', 'name' => 'Keine Tracking-Scripte erkannt', ]; } // Footer-Links prüfen preg_match( '//i', $html, $footer_match ); $footer_html = ! empty( $footer_match[0] ) ? strtolower( $footer_match[0] ) : $html_lower; $impressum_in_footer = $this->has_real_link( $footer_html, [ 'impressum', 'imprint' ] ); if ( ! empty( $topic_exists['impressum'] ) && ! $impressum_in_footer ) { $findings[] = [ 'id' => 'html-no-impressum-link', 'name' => 'Impressum nicht im Footer verlinkt', 'severity' => 'critical', 'category' => 'html-check', 'explanation' => 'Das Impressum muss von jeder Seite aus mit maximal 2 Klicks erreichbar sein (DDG §5).', 'quickFix' => 'WordPress > Design > Menüs > Footer-Menü > Link zu Impressum hinzufügen.', 'source' => 'HTML-Analyse', ]; } elseif ( $impressum_in_footer && ! empty( $topic_exists['impressum'] ) ) { // Bewusst nur "bestanden", wenn die Impressum-Seite auch existiert. Sonst // stuende "Impressum fehlt" und "Impressum verlinkt" im selben Report. $passed[] = [ 'id' => 'pass-impressum-link', 'name' => 'Impressum im Footer verlinkt', ]; } $privacy_in_footer = $this->has_real_link( $footer_html, [ 'datenschutz', 'privacy' ] ); if ( ! empty( $topic_exists['datenschutz'] ) && ! $privacy_in_footer ) { $findings[] = [ 'id' => 'html-no-privacy-link', 'name' => 'Datenschutzerklärung nicht im Footer verlinkt', 'severity' => 'critical', 'category' => 'html-check', 'explanation' => 'Die Datenschutzerklärung muss von jeder Seite aus erreichbar sein (DSGVO Art. 13).', 'quickFix' => 'WordPress > Design > Menüs > Footer-Menü > Link zu Datenschutzerklärung hinzufügen.', 'source' => 'HTML-Analyse', ]; } elseif ( $privacy_in_footer ) { $passed[] = [ 'id' => 'pass-privacy-link', 'name' => 'Datenschutzerklärung im Footer verlinkt', ]; } // Grundpreisangaben-Hinweis (PAngV) if ( class_exists( 'WooCommerce' ) ) { $has_pangv_plugin = is_plugin_active( 'woocommerce-germanized/woocommerce-germanized.php' ) || is_plugin_active( 'german-market/german-market.php' ) || is_plugin_active( 'b2b-market/b2b-market.php' ); if ( ! $has_pangv_plugin ) { $findings[] = [ 'id' => 'pangv-missing', 'name' => 'Grundpreisangaben (PAngV) nicht sichergestellt', 'severity' => 'warning', 'category' => 'wordpress', 'explanation' => 'Kein Plugin für deutsche Preisauszeichnung erkannt (Germanized, German Market). Die PAngV verlangt Grundpreise bei nach Gewicht/Volumen verkauften Produkten.', 'quickFix' => 'Plugin "Germanized for WooCommerce" oder "German Market" installieren. Diese ergänzen Grundpreise, Lieferzeiten und Streichpreise automatisch.', 'source' => 'Plugin-Analyse', ]; } else { $passed[] = [ 'id' => 'pass-pangv', 'name' => 'Grundpreis-Plugin aktiv', ]; } } return [ 'findings' => $findings, 'passed' => $passed ]; } }