Files
GDPR-Content-Blocker/gdpr-content-blocker/includes/class-fonts.php
s4luorth 377c64f8d0 feat: lokales Google-Fonts-Hosting + "Lizenz upgraden"-button (v1.3.0)
- Lizenz-Tab: bei vollen Plaetzen erscheint ein "Lizenz upgraden"-Button
  (https://lucas-orth.de/wp-plugins/gdpr-content-blocker/) zusaetzlich zur
  domain-freigabe.
- Neues Feature "Schriften": Google Fonts lokal hosten (button-modell).
  Admin klickt "Jetzt herunterladen & einbinden" -> plugin scannt die seiten,
  laedt googleapis-CSS + gstatic-schriftdateien einmalig in den uploads-ordner,
  schreibt die pfade lokal um und tauscht sie per output-buffer im quelltext.
  Abgedeckt: <link>, inline-@import/url() UND @import/url() in gleicher-origin
  externen CSS-dateien (mit absolutierung der restlichen relativen pfade).
  preconnect/dns-prefetch zu google-font-hosts werden entfernt (kein IP-leak).
  Toggle zum aus/einschalten + "Lokale Schriften loeschen".
- i18n-pipeline robuster: build-en-mo.py nutzt jetzt translations-en.json
  (lookup per quell-string, reihenfolge-unabhaengig) und bricht mit liste ab,
  falls eine uebersetzung fehlt. Alle neuen strings DE/EN ergaenzt (139).
- version 1.3.0 (header + CB_VERSION).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:16:19 +02:00

530 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
defined( 'ABSPATH' ) || exit;
/**
* Optional: host Google Fonts locally to avoid the connection to Google's CDN
* (privacy + performance).
*
* Trigger model: "button". The admin clicks "download & embed"; the plugin
* visits the configured pages, finds every Google Fonts reference and downloads
* everything once:
* - <link href="…fonts.googleapis.com/css…"> (also protocol-relative)
* - @import / direct gstatic URLs inside inline <style> blocks
* - @import / url(…gstatic…) inside SAME-ORIGIN external CSS files
* The CSS + font files are stored under wp-content/uploads, all paths are
* rewritten to the local copies, and a remote→local URL map is saved. On the
* front end a lightweight output-buffer pass swaps the mapped URLs (no network).
*
* Anything not covered by the scanned pages simply stays remote until the next
* "download & embed" run (predictable, no per-visitor latency).
*/
class CB_Fonts {
const OPTION = 'cb_fonts';
const DIR = 'gdpr-content-blocker-fonts';
// Modern browser UA so fonts.googleapis.com returns woff2 (not legacy ttf).
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const MAX_CSS = 40;
const MAX_FONTS = 200;
/* Per-build memoisation (avoid re-downloading across pages). */
private static array $css_done = [];
private static array $font_done = [];
private static array $map = [];
private static int $font_count = 0;
private static int $css_count = 0;
public static function init(): void {
add_action( 'template_redirect', [ __CLASS__, 'start_buffer' ], 5 );
add_action( 'admin_post_cb_save_fonts', [ __CLASS__, 'handle_save' ] );
add_action( 'wp_ajax_cb_fonts_fetch', [ __CLASS__, 'ajax_fetch' ] );
add_action( 'wp_ajax_cb_fonts_clear', [ __CLASS__, 'ajax_clear' ] );
}
public static function get_settings(): array {
$s = get_option( self::OPTION, [] );
$s = wp_parse_args( is_array( $s ) ? $s : [], [
'enabled' => false,
'map' => [],
'updated_at' => 0,
'count' => 0,
'css_count' => 0,
] );
if ( ! is_array( $s['map'] ) ) {
$s['map'] = [];
}
return $s;
}
public static function is_enabled(): bool {
$s = self::get_settings();
return ! empty( $s['enabled'] ) && ! empty( $s['map'] );
}
/* ───────────────────────── front-end rewrite ───────────────────────── */
public static function start_buffer(): void {
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
return;
}
if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
return;
}
if ( ! self::is_enabled() ) {
return;
}
ob_start( [ __CLASS__, 'rewrite' ] );
}
public static function rewrite( string $html ): string {
if ( $html === '' ) {
return $html;
}
$map = self::get_settings()['map'];
if ( empty( $map ) ) {
return $html;
}
// Longest keys first so a shorter URL can't pre-empt a longer one.
uksort( $map, fn( $a, $b ) => strlen( (string) $b ) <=> strlen( (string) $a ) );
$html = str_replace( array_keys( $map ), array_values( $map ), $html );
// Drop now-pointless resource hints to Google's font hosts — a preconnect
// would still open a connection and leak the visitor's IP. Match any
// attribute order via look-aheads.
$out = preg_replace(
'#<link\b(?=[^>]*\brel=["\'](?:preconnect|dns-prefetch|preload)["\'])(?=[^>]*(?:fonts\.googleapis\.com|fonts\.gstatic\.com))[^>]*>\s*#i',
'',
$html
);
return $out === null ? $html : $out;
}
/* ───────────────────────── admin actions ───────────────────────── */
public static function handle_save(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Keine Berechtigung.', 'gdpr-content-blocker' ) );
}
check_admin_referer( 'cb_fonts', 'cb_fonts_nonce' );
$s = self::get_settings();
$s['enabled'] = ! empty( $_POST['cb_fonts_enabled'] );
update_option( self::OPTION, $s );
wp_safe_redirect( admin_url( 'options-general.php?page=gdpr-content-blocker&cb_tab=fonts&cb_saved=1' ) );
exit;
}
public static function ajax_fetch(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Keine Berechtigung.', 'gdpr-content-blocker' ) ], 403 );
}
check_ajax_referer( 'cb_fonts', 'nonce' );
$res = self::build_cache();
if ( is_wp_error( $res ) ) {
wp_send_json_error( [ 'message' => $res->get_error_message() ] );
}
wp_send_json_success( $res );
}
public static function ajax_clear(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Keine Berechtigung.', 'gdpr-content-blocker' ) ], 403 );
}
check_ajax_referer( 'cb_fonts', 'nonce' );
self::delete_dir();
$s = self::get_settings();
$s['map'] = [];
$s['count'] = 0;
$s['css_count'] = 0;
$s['updated_at'] = 0;
update_option( self::OPTION, $s );
wp_send_json_success( [ 'message' => __( 'Lokale Schriften gelöscht.', 'gdpr-content-blocker' ) ] );
}
/* ───────────────────────── download / cache ───────────────────────── */
private static function build_cache(): array|WP_Error {
self::$css_done = [];
self::$font_done = [];
self::$map = [];
self::$font_count = 0;
self::$css_count = 0;
$upload = wp_upload_dir();
if ( ! empty( $upload['error'] ) ) {
return new WP_Error( 'cb_upload', (string) $upload['error'] );
}
$dir = trailingslashit( $upload['basedir'] ) . self::DIR;
if ( ! wp_mkdir_p( $dir ) ) {
return new WP_Error( 'cb_mkdir', __( 'Upload-Verzeichnis konnte nicht erstellt werden.', 'gdpr-content-blocker' ) );
}
$home_host = self::host( home_url() );
$pages = CB_Settings::scan_urls();
foreach ( $pages as $page ) {
$html = self::http_get( $page );
if ( $html === null ) {
continue;
}
// 1) Google Fonts stylesheets referenced directly (link href / @import).
if ( preg_match_all( '#(?:https?:)?//fonts\.googleapis\.com/[^"\'()\s<>]+#i', $html, $g ) ) {
foreach ( array_unique( $g[0] ) as $found ) {
if ( isset( self::$map[ $found ] ) ) {
continue;
}
$local = self::process_googleapis_css( self::abs_url( $found ) );
if ( $local ) {
self::$map[ $found ] = $local;
}
}
}
// 2) Direct gstatic font URLs in the HTML (e.g. inline @font-face).
if ( preg_match_all( '#(?:https?:)?//fonts\.gstatic\.com/[^"\'()\s<>]+#i', $html, $gs ) ) {
foreach ( array_unique( $gs[0] ) as $found ) {
if ( isset( self::$map[ $found ] ) ) {
continue;
}
$local = self::download_font( self::abs_url( $found ) );
if ( $local ) {
self::$map[ $found ] = $local;
}
}
}
// 3) Same-origin external CSS files that themselves reference Google Fonts.
if ( preg_match_all( '#<link\b[^>]*>#i', $html, $links ) ) {
foreach ( $links[0] as $tag ) {
if ( self::$css_count >= self::MAX_CSS ) {
break;
}
$rel = self::attr( $tag, 'rel' );
$href = self::attr( $tag, 'href' );
if ( $href === '' || stripos( $rel, 'stylesheet' ) === false ) {
continue;
}
$abs = self::abs_url( $href );
$h = self::host( $abs );
if ( $h === 'fonts.googleapis.com' || $h !== $home_host ) {
continue; // googleapis handled in (1); only our own CSS files here
}
$cache_key = 'ext:' . $abs;
if ( isset( self::$css_done[ $cache_key ] ) ) {
continue;
}
self::$css_done[ $cache_key ] = true;
$css = self::http_get( $abs, true );
if ( $css === null
|| ( stripos( $css, 'fonts.googleapis.com' ) === false
&& stripos( $css, 'fonts.gstatic.com' ) === false ) ) {
continue;
}
$localized = self::localize_css( $css, $abs, true );
if ( $localized !== $css ) {
$url = self::save( 'css', $localized );
if ( $url ) {
self::$map[ $href ] = $url;
self::$css_count++;
}
}
}
}
}
$s = self::get_settings();
$s['map'] = self::$map;
$s['count'] = self::$font_count;
$s['css_count'] = self::$css_count;
$s['updated_at'] = time();
$s['enabled'] = true; // a successful build implies the feature is wanted
update_option( self::OPTION, $s );
return [
'fonts' => self::$font_count,
'css' => self::$css_count,
'mapped' => count( self::$map ),
'message' => sprintf(
/* translators: 1: number of stylesheets, 2: number of font files */
__( '%1$d Schrift-Stylesheet(s) und %2$d Schriftdatei(en) lokal gespeichert.', 'gdpr-content-blocker' ),
self::$css_count,
self::$font_count
),
];
}
/** Fetch a googleapis CSS, localise its gstatic URLs, store it; return local URL. */
private static function process_googleapis_css( string $absUrl ): ?string {
if ( array_key_exists( $absUrl, self::$css_done ) ) {
return self::$css_done[ $absUrl ];
}
if ( self::$css_count >= self::MAX_CSS || self::host( $absUrl ) !== 'fonts.googleapis.com' ) {
return null;
}
$css = self::http_get( $absUrl, true );
if ( $css === null ) {
self::$css_done[ $absUrl ] = null;
return null;
}
$css = self::localize_css( $css, $absUrl, false );
$url = self::save( 'css', $css );
if ( $url ) {
self::$css_count++;
}
self::$css_done[ $absUrl ] = $url;
return $url;
}
/** Download a single gstatic font file; return its local URL. */
private static function download_font( string $absUrl ): ?string {
if ( array_key_exists( $absUrl, self::$font_done ) ) {
return self::$font_done[ $absUrl ];
}
if ( self::$font_count >= self::MAX_FONTS || self::host( $absUrl ) !== 'fonts.gstatic.com' ) {
return null;
}
$bin = self::http_get( $absUrl );
if ( $bin === null ) {
self::$font_done[ $absUrl ] = null;
return null;
}
$url = self::save( self::ext_from_url( $absUrl ), $bin );
if ( $url ) {
self::$font_count++;
}
self::$font_done[ $absUrl ] = $url;
return $url;
}
/**
* Rewrite a CSS body: googleapis @import → local; gstatic url() → local file.
* When $absolutize is true (external theme CSS we copy elsewhere), all other
* relative url()s are made absolute against $baseUrl so they keep working.
*/
private static function localize_css( string $css, string $baseUrl, bool $absolutize ): string {
// @import url("…googleapis…") or @import "…googleapis…"
$css = preg_replace_callback(
'#@import\s+(?:url\(\s*)?([\'"]?)([^\'"\)\s]+)\1\s*\)?\s*;#i',
function ( array $m ): string {
$abs = self::abs_url( $m[2] );
if ( self::host( $abs ) !== 'fonts.googleapis.com' ) {
return $m[0];
}
$local = self::process_googleapis_css( $abs );
return $local ? '@import url("' . $local . '");' : $m[0];
},
$css
) ?? $css;
// url(…gstatic…woff2) → local
$css = preg_replace_callback(
'#url\(\s*([\'"]?)([^\'"\)\s]+)\1\s*\)#i',
function ( array $m ) use ( $baseUrl, $absolutize ): string {
$raw = $m[2];
$abs = self::abs_url( $raw, $baseUrl );
if ( self::host( $abs ) === 'fonts.gstatic.com' ) {
$local = self::download_font( $abs );
return $local ? 'url("' . $local . '")' : $m[0];
}
// Keep other resources working when we relocate the CSS file.
if ( $absolutize && ! preg_match( '#^(data:|https?://|//)#i', $raw ) ) {
return 'url("' . $abs . '")';
}
return $m[0];
},
$css
) ?? $css;
return $css;
}
/* ───────────────────────── small helpers ───────────────────────── */
/** GET a URL with a browser UA. Returns the body (string) or null on failure. */
private static function http_get( string $url, bool $css = false ): ?string {
$args = [
'timeout' => 20,
'redirection' => 3,
'user-agent' => self::UA,
];
if ( $css ) {
$args['headers'] = [ 'Accept' => 'text/css,*/*;q=0.1' ];
}
$resp = wp_remote_get( $url, $args );
if ( is_wp_error( $resp ) || wp_remote_retrieve_response_code( $resp ) !== 200 ) {
return null;
}
$body = wp_remote_retrieve_body( $resp );
return $body === '' ? null : $body;
}
/** Save bytes to the uploads font dir (deduped by content hash); return URL. */
private static function save( string $ext, string $contents ): ?string {
$upload = wp_upload_dir();
if ( ! empty( $upload['error'] ) ) {
return null;
}
$dir = trailingslashit( $upload['basedir'] ) . self::DIR;
if ( ! wp_mkdir_p( $dir ) ) {
return null;
}
$ext = preg_replace( '/[^a-z0-9]/', '', strtolower( $ext ) ) ?: 'bin';
$name = md5( $contents ) . '.' . $ext;
$path = trailingslashit( $dir ) . $name;
if ( ! file_exists( $path ) && file_put_contents( $path, $contents ) === false ) {
return null;
}
return trailingslashit( $upload['baseurl'] ) . self::DIR . '/' . $name;
}
private static function delete_dir(): void {
$upload = wp_upload_dir();
if ( ! empty( $upload['error'] ) ) {
return;
}
$dir = trailingslashit( $upload['basedir'] ) . self::DIR;
if ( ! is_dir( $dir ) ) {
return;
}
foreach ( (array) glob( trailingslashit( $dir ) . '*' ) as $file ) {
if ( is_file( $file ) ) {
@unlink( $file );
}
}
}
/** Resolve protocol-relative / relative URLs (and decode HTML entities). */
private static function abs_url( string $url, string $base = '' ): string {
$url = html_entity_decode( trim( $url ), ENT_QUOTES );
if ( str_starts_with( $url, '//' ) ) {
return 'https:' . $url;
}
if ( preg_match( '#^(data:|https?://)#i', $url ) ) {
return $url;
}
if ( $base === '' ) {
return $url;
}
// Relative → absolute against $base.
$b = wp_parse_url( $base );
$scheme = $b['scheme'] ?? 'https';
$host = $b['host'] ?? '';
$port = isset( $b['port'] ) ? ':' . $b['port'] : '';
$path = $b['path'] ?? '/';
if ( str_starts_with( $url, '/' ) ) {
$full = $url;
} else {
$full = preg_replace( '#/[^/]*$#', '/', $path ) . $url;
}
$segs = [];
foreach ( explode( '/', $full ) as $seg ) {
if ( $seg === '..' ) {
array_pop( $segs );
} elseif ( $seg !== '.' && $seg !== '' ) {
$segs[] = $seg;
}
}
return $scheme . '://' . $host . $port . '/' . implode( '/', $segs );
}
private static function host( string $url ): string {
return strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
}
private static function ext_from_url( string $url ): string {
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
$ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
return in_array( $ext, [ 'woff2', 'woff', 'ttf', 'otf', 'eot', 'svg' ], true ) ? $ext : 'woff2';
}
/** Read a single attribute value out of an HTML tag string. */
private static function attr( string $tag, string $name ): string {
$re = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i';
if ( preg_match( $re, $tag, $m ) ) {
if ( ( $m[2] ?? '' ) !== '' ) {
return $m[2];
}
if ( ( $m[3] ?? '' ) !== '' ) {
return $m[3];
}
return $m[4] ?? '';
}
return '';
}
/* ───────────────────────── settings tab ───────────────────────── */
public static function render_tab(): void {
$s = self::get_settings();
$has_cache = ! empty( $s['map'] );
$when = $s['updated_at']
? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $s['updated_at'] )
: '';
?>
<p class="description" style="max-width:760px;">
<?php esc_html_e( 'Bindet Google Fonts lokal ein: Das Plugin lädt die Schrift-Stylesheets und Schriftdateien einmalig herunter, speichert sie in Ihrem Uploads-Ordner und ersetzt die Pfade im Seitenquelltext. So wird beim Seitenaufruf keine Verbindung mehr zu Google aufgebaut (kein IP-Transfer) und die Schriften laden schneller.', 'gdpr-content-blocker' ); ?>
</p>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'cb_fonts', 'cb_fonts_nonce' ); ?>
<input type="hidden" name="action" value="cb_save_fonts">
<table class="form-table" role="presentation"><tbody>
<tr>
<th scope="row"><?php esc_html_e( 'Lokales Hosting', 'gdpr-content-blocker' ); ?></th>
<td>
<label class="cb-switch" title="<?php esc_attr_e( 'Lokale Schriften ein/aus', 'gdpr-content-blocker' ); ?>">
<input type="checkbox" name="cb_fonts_enabled" value="1" <?php checked( ! empty( $s['enabled'] ) ); ?>>
<span class="cb-switch__slider"></span>
</label>
<p class="description"><?php esc_html_e( 'Wenn aktiv, werden auf der Webseite die heruntergeladenen lokalen Schriften statt der Google-Pfade ausgeliefert.', 'gdpr-content-blocker' ); ?></p>
</td>
</tr>
</tbody></table>
<?php submit_button( __( 'Speichern', 'gdpr-content-blocker' ) ); ?>
</form>
<hr>
<h2><?php esc_html_e( 'Schriften herunterladen', 'gdpr-content-blocker' ); ?></h2>
<p>
<button type="button" id="cb-fonts-fetch" class="button button-primary">
<?php esc_html_e( 'Jetzt herunterladen & einbinden', 'gdpr-content-blocker' ); ?>
</button>
<?php if ( $has_cache ) : ?>
<button type="button" id="cb-fonts-clear" class="button" style="margin-left:6px;">
<?php esc_html_e( 'Lokale Schriften löschen', 'gdpr-content-blocker' ); ?>
</button>
<?php endif; ?>
<span id="cb-fonts-status" style="margin-left:10px;"></span>
</p>
<?php if ( $has_cache ) : ?>
<p class="description">
<?php
printf(
/* translators: 1: stylesheet count, 2: font file count, 3: date/time */
esc_html__( 'Aktuell lokal: %1$d Stylesheet(s), %2$d Schriftdatei(en) – zuletzt aktualisiert am %3$s.', 'gdpr-content-blocker' ),
(int) ( $s['css_count'] ?? 0 ),
(int) ( $s['count'] ?? 0 ),
esc_html( $when )
);
?>
</p>
<?php endif; ?>
<p class="description" style="max-width:760px;">
<?php esc_html_e( 'Hinweis: Gescannt werden die Startseite und einige Unterseiten. Falls auf einzelnen Seiten weitere Google Fonts vorkommen, führen Sie die Aktion nach Inhaltsänderungen erneut aus.', 'gdpr-content-blocker' ); ?>
</p>
<?php
}
}