= self::BLOCK_REVISION || ! current_user_can( 'manage_options' ) ) { return; } update_option( self::OPTION_BLOCK_REVISION, self::BLOCK_REVISION, false ); if ( self::is_supported() && array() !== self::current_block_lines() ) { self::apply( Wp360_Speed_Optimizer_Settings::get() ); } } public static function on_activate() { // Bewusst kein automatisches Schreiben beim Aktivieren -- die // .htaccess bleibt unveraendert, bis der Nutzer aktiv "Quickstart" // klickt oder Einstellungen speichert. } public static function on_deactivate() { if ( self::is_supported() ) { self::write_block( array() ); } } public static function is_supported() { $path = self::htaccess_path(); return is_file( $path ) ? wp_is_writable( $path ) : wp_is_writable( dirname( $path ) ); } /** Bekannte Apache-Module, sofern vom Server ueberhaupt auslesbar (viele Hoster deaktivieren apache_get_modules() aus Sicherheitsgruenden). */ public static function detected_modules() { if ( ! function_exists( 'apache_get_modules' ) ) { return null; } $modules = @apache_get_modules(); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! is_array( $modules ) ) { return null; } return array( 'mod_brotli' => in_array( 'mod_brotli', $modules, true ), 'mod_deflate' => in_array( 'mod_deflate', $modules, true ), 'mod_expires' => in_array( 'mod_expires', $modules, true ), 'mod_headers' => in_array( 'mod_headers', $modules, true ), 'mod_rewrite' => in_array( 'mod_rewrite', $modules, true ), ); } private static function htaccess_path() { require_once ABSPATH . 'wp-admin/includes/file.php'; return get_home_path() . '.htaccess'; } public static function status() { $path = self::htaccess_path(); $backup = get_option( self::OPTION_BACKUP, array() ); $last_test = get_option( self::OPTION_LAST_TEST, array() ); $is_windows = 0 === stripos( PHP_OS, 'WIN' ); return array( 'path' => $path, 'exists' => is_file( $path ), 'writable' => is_file( $path ) ? wp_is_writable( $path ) : wp_is_writable( dirname( $path ) ), 'is_windows' => $is_windows, 'is_nginx' => self::is_nginx(), 'server_software' => self::server_software(), 'modules' => self::detected_modules(), 'block_present' => self::current_block_lines() !== array(), 'last_written' => (int) get_option( self::OPTION_LAST_WRITTEN, 0 ), 'backup_available' => ! empty( $backup['content'] ), 'backup_time' => (int) ( $backup['time'] ?? 0 ), 'last_test' => is_array( $last_test ) ? $last_test : array(), ); } /** * .htaccess wirkt nur unter Apache (und kompatiblen Servern wie * LiteSpeed/OpenLiteSpeed, die dieselbe Datei auswerten) -- unter Nginx * liest niemand diese Datei, das Plugin schriebe sie bislang trotzdem, * ohne jede Wirkung (siehe docs/360-orbit-speed-optimizer.md, Idee #2). Bewusst * eng auf "nginx" geprueft statt auf "alles ausser Apache": eine falsche * Warnung ("wirkt nicht") auf einem Server, auf dem es tatsaechlich * funktioniert (z. B. LiteSpeed), waere schaedlicher als eine ausbleibende * Warnung. Ueber den Filter korrigierbar, falls z. B. eine Nginx-Instanz * nur als Reverse-Proxy vor einem Apache-Backend steht, wo .htaccess * weiterhin greift. */ /** * Name der Server-Software, bereinigt (kommt vom Server, ist aber nicht fest). * * @return string */ private static function server_software() { return isset( $_SERVER['SERVER_SOFTWARE'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ), 0, 100 ) : ''; } public static function is_nginx() { $software = self::server_software(); return (bool) apply_filters( 'wp360_so_is_nginx', false !== stripos( $software, 'nginx' ), $software ); } private static function current_block_lines() { require_once ABSPATH . 'wp-admin/includes/misc.php'; $path = self::htaccess_path(); if ( ! is_file( $path ) ) { return array(); } return (array) extract_from_markers( $path, self::MARKER ); } /** Baut den kompletten, unmarkierten Regelblock aus den Einstellungen. Reihenfolge/Inhalt haengt eng mit Wp360_Speed_Optimizer_Settings zusammen. */ public static function build_block( array $settings ) { $lines = array(); // Brotli VOR Gzip: Apache haengt die Filter in der Reihenfolge ihrer // Deklaration an. Steht Gzip zuerst, komprimiert es auch fuer Browser, // die Brotli koennten -- Brotli sieht dann schon ein kodiertes Ergebnis // und tut nichts. So bekommt jeder Browser mit "br" Brotli, alle // anderen weiter Gzip. Fehlt mod_brotli, ueberspringt den // Block ohne Fehler. if ( ! empty( $settings['brotli_enabled'] ) ) { $lines = array_merge( $lines, array( '', ' AddOutputFilterByType BROTLI_COMPRESS text/plain text/css text/xml text/javascript', ' AddOutputFilterByType BROTLI_COMPRESS application/javascript application/json application/xml application/rss+xml', ' AddOutputFilterByType BROTLI_COMPRESS image/svg+xml font/ttf font/otf application/vnd.ms-fontobject', '', '', ) ); } if ( $settings['gzip_enabled'] ) { $lines = array_merge( $lines, array( '', ' AddOutputFilterByType DEFLATE text/plain text/css text/xml text/javascript', ' AddOutputFilterByType DEFLATE application/javascript application/json application/xml application/rss+xml', ' AddOutputFilterByType DEFLATE image/svg+xml font/ttf font/otf application/vnd.ms-fontobject', '', '', ) ); } if ( $settings['caching_enabled'] ) { $days = $settings['cache_days']; $extension_days = array(); foreach ( Wp360_Speed_Optimizer_Settings::CACHE_GROUPS as $group => $extensions ) { foreach ( $extensions as $extension ) { $extension_days[ $extension ] = $days[ $group ]; } } $expires_lines = array( '', ' ExpiresActive On' ); foreach ( $extension_days as $extension => $group_days ) { $expires_lines[] = sprintf( ' ExpiresByType %s "access plus %d days"', self::mime_for_extension( $extension ), $group_days ); } // Kein ExpiresDefault: der griffe auch fuer von PHP erzeugte Seiten // ohne eigenes Expires -- anonyme HTML-Seiten und REST-Antworten // laegen dann tagelang in Browser- und Proxy-Caches. Die // Standard-Dauer fuer uebrige statische Dateien setzt die // FilesMatch-Regel unten. $expires_lines[] = ''; $expires_lines[] = ''; $pattern = implode( '|', array_map( 'preg_quote', array_keys( $extension_days ) ) ); $max_age_default = DAY_IN_SECONDS * $days['default']; $headers_lines = array( '' ); // Je Cache-Gruppe eine eigene FilesMatch-Regel -- vorher teilten sich // ALLE bekannten Endungen eine gemeinsame Regel mit dem LAENGSTEN // konfigurierten Wert unter den Gruppen (siehe ehemalige // max_age_for_groups()): css_js (Standard: 30 Tage) bekam dadurch // faelschlich denselben Cache-Control-Wert wie Bilder/Schriftarten // (Standard: 365 Tage) -- ExpiresByType oben war schon immer korrekt // pro Erweiterung, nur der tatsaechlich von Browsern bevorzugte // Cache-Control-Header nicht. foreach ( Wp360_Speed_Optimizer_Settings::CACHE_GROUPS as $group => $extensions ) { $group_pattern = implode( '|', array_map( 'preg_quote', $extensions ) ); // Ohne Query-String-Versionierung (remove_query_strings) aendert // sich die URL eines aktualisierten CSS/JS-Assets nie -- ein langer // max-age wuerde ein Update dann bis zu ein Jahr lang vor jedem // Browser verstecken, der die Seite vorher schon besucht hat. // "no-cache" bedeutet hier (trotz des irrefuehrenden Namens) nicht // "nicht cachen", sondern "vor jeder Verwendung per ETag/ // Last-Modified beim Server nachfragen" -- meist ein schneller // 304 statt Neuladen, eine echte Aenderung kommt aber garantiert // beim naechsten Aufruf an. if ( $settings['remove_query_strings'] && 'css_js' === $group ) { $cache_control = 'no-cache'; } else { $cache_control = 'public, max-age=' . ( DAY_IN_SECONDS * $days[ $group ] ); } $headers_lines[] = sprintf( ' ', $group_pattern ); $headers_lines[] = ' Header set Cache-Control "' . $cache_control . '"'; $headers_lines[] = ' '; } // Uebrige statische Dateien -- ausdruecklich NICHT PHP und HTML: // Permalinks, wp-admin, wp-login.php und /wp-json/ laufen alle // ueber .php-Dateien, und "Header set" ersetzt dort WordPress' // eigenes "no-store, private". Eingeloggte Seiten (Admin-Bar, // Nonces, Kundendaten) landeten sonst als "public" in // vorgeschalteten Caches (Varnish, CDN) und gingen an Fremde. $headers_lines[] = ' '; $headers_lines[] = ' Header set Cache-Control "public, max-age=' . $max_age_default . '"'; $headers_lines[] = ' '; $headers_lines[] = ''; $headers_lines[] = ''; $lines = array_merge( $lines, $expires_lines, $headers_lines ); } if ( $settings['etags_removed'] ) { $lines = array_merge( $lines, array( 'FileETag None', '', ' Header unset ETag', '', '', ) ); } // Trailing Leerzeile entfernen -- insert_with_markers fuegt selbst sauber ab. while ( ! empty( $lines ) && '' === end( $lines ) ) { array_pop( $lines ); } return $lines; } private static function mime_for_extension( $extension ) { $map = array( 'jpg' => 'image/jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'svg' => 'image/svg+xml', 'ico' => 'image/x-icon', 'css' => 'text/css', 'js' => 'application/javascript', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'otf' => 'font/otf', 'eot' => 'application/vnd.ms-fontobject', ); return $map[ $extension ] ?? 'application/octet-stream'; } /** * Schreibt den Block neu: sichert vorher den aktuellen Stand, schreibt, * fuehrt den Selbsttest aus und rollt bei Fehlschlag automatisch zurueck. * * @return array{written: bool, test: array} */ public static function apply( array $settings ) { return self::write_block( self::build_block( $settings ) ); } private static function write_block( array $lines ) { if ( ! self::htaccess_writable() ) { return array( 'written' => false, 'test' => array( 'ok' => false, 'message' => __( 'The .htaccess file is not writable. Please adjust file permissions or edit it manually.', '360-orbit-speed-optimizer' ), ), ); } require_once ABSPATH . 'wp-admin/includes/misc.php'; $previous = self::current_block_lines(); update_option( self::OPTION_BACKUP, array( 'content' => $previous, 'time' => time(), ), false ); $path = self::htaccess_path(); self::insert_block( $path, $lines ); update_option( self::OPTION_LAST_WRITTEN, time(), false ); $test = self::self_test(); update_option( self::OPTION_LAST_TEST, $test, false ); if ( ! $test['ok'] ) { self::insert_block( $path, $previous ); $test['rolled_back'] = true; } return array( 'written' => true, 'test' => $test, ); } public static function restore_backup() { if ( ! self::htaccess_writable() ) { return array( 'ok' => false, 'message' => __( 'The .htaccess file is not writable.', '360-orbit-speed-optimizer' ), ); } require_once ABSPATH . 'wp-admin/includes/misc.php'; $backup = get_option( self::OPTION_BACKUP, array() ); self::insert_block( self::htaccess_path(), (array) ( $backup['content'] ?? array() ) ); update_option( self::OPTION_LAST_WRITTEN, time(), false ); $test = self::self_test(); update_option( self::OPTION_LAST_TEST, $test, false ); return $test; } private static function htaccess_writable() { $path = self::htaccess_path(); return is_file( $path ) ? wp_is_writable( $path ) : wp_is_writable( dirname( $path ) ); } /** Loopback-Request auf die Startseite -- entscheidet ueber automatischen Rollback. */ public static function self_test() { $response = wp_remote_get( home_url( '/' ), array( 'timeout' => 10, 'sslverify' => false, 'headers' => array( 'Cache-Control' => 'no-cache' ), ) ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'message' => sprintf( /* translators: %s: underlying error message */ __( 'The homepage could not be reached: %s', '360-orbit-speed-optimizer' ), $response->get_error_message() ), 'time' => time(), ); } $code = wp_remote_retrieve_response_code( $response ); if ( $code >= 500 ) { return array( 'ok' => false, /* translators: %d: HTTP status code */ 'message' => sprintf( __( 'The homepage responded with HTTP %d after the change.', '360-orbit-speed-optimizer' ), $code ), 'time' => time(), ); } return array( 'ok' => true, /* translators: %d: HTTP status code */ 'message' => sprintf( __( 'The homepage responded with HTTP %d.', '360-orbit-speed-optimizer' ), $code ), 'time' => time(), ); } /** * insert_with_markers() schreibt die Datei per implode("\n") -- steht * unser Block am Dateiende, fehlt danach der abschliessende Zeilenumbruch. * Ein spaeter angehaengter Block (z. B. der WordPress-eigene) klebt dann an * der END-Markierung ("# END …# BEGIN WordPress") und macht sie unbrauchbar. */ private static function insert_block( $path, array $lines ) { $result = insert_with_markers( $path, self::MARKER, $lines ); if ( $result && is_readable( $path ) ) { $contents = (string) file_get_contents( $path ); if ( '' !== $contents && "\n" !== substr( $contents, -1 ) ) { file_put_contents( $path, $contents . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } return $result; } }