<?php
/**
* Local Google Fonts cache.
*
* @package PoweredCache
*/
namespace PoweredCache;
use function PoweredCache\Utils\get_cache_dir;
/**
* Caches Google Fonts stylesheets and font files locally.
*
* @since 4.0.0
*/
class GoogleFontsLocalCache {
const CSS_MAX_BYTES = 200000;
const FONT_MAX_BYTES = 3000000;
const FAILURE_TTL = 300;
/**
* Cache a Google Fonts stylesheet and return the local URL.
*
* @param string $stylesheet_url Remote stylesheet URL.
*
* @return string
*/
public function get_stylesheet_url( $stylesheet_url ) {
$stylesheet_url = $this->normalize_url( $stylesheet_url );
if ( '' === $stylesheet_url || ! $this->is_allowed_stylesheet_url( $stylesheet_url ) ) {
return $stylesheet_url;
}
$cache_dir = $this->cache_dir();
$cache_url = $this->cache_url();
if ( '' === $cache_dir || '' === $cache_url || ! $this->ensure_dir( $cache_dir ) ) {
return $stylesheet_url;
}
$file = $cache_dir . md5( $stylesheet_url ) . '.css';
$url = $cache_url . basename( $file );
if ( $this->is_fresh( $file, $this->ttl() ) ) {
return $url;
}
$css = $this->remote_get_body( $stylesheet_url, self::CSS_MAX_BYTES );
if ( '' === $css ) {
return file_exists( $file ) ? $url : $stylesheet_url;
}
$css = $this->rewrite_font_urls( $css, $stylesheet_url );
if ( ! $this->write_file( $file, $css ) ) {
return $stylesheet_url;
}
return $url;
}
/**
* Replace Google Fonts stylesheet references in rendered HTML.
*
* @param string $html Rendered HTML.
*
* @return string
*/
public function replace_stylesheet_urls( $html ) {
if ( '' === $html || false === stripos( $html, 'fonts.' ) ) {
return $html;
}
return preg_replace_callback(
'/\shref=(["\'])([^"\']*(?:fonts\.googleapis\.com|fonts\.bunny\.net)\/css[^"\']*)\1/i',
function ( $matches ) {
return ' href=' . $matches[1] . $this->get_stylesheet_url( $matches[2] ) . $matches[1];
},
$html
);
}
/**
* Return the cache directory.
*
* @return string
*/
public function cache_dir() {
return trailingslashit( get_cache_dir() ) . 'powered-cache/google-fonts/';
}
/**
* Return the web URL for the cache directory.
*
* @return string
*/
public function cache_url() {
if ( ! defined( 'WP_CONTENT_DIR' ) || ! function_exists( 'content_url' ) ) {
return '';
}
$cache_dir = $this->normalize_path( $this->cache_dir() );
$content_dir = trailingslashit( $this->normalize_path( WP_CONTENT_DIR ) );
if ( 0 !== strpos( $cache_dir, $content_dir ) ) {
return '';
}
$relative_path = ltrim( substr( $cache_dir, strlen( $content_dir ) ), '/' );
return trailingslashit( content_url( $relative_path ) );
}
/**
* Rewrite remote font URLs inside a stylesheet to local cached URLs.
*
* @param string $css Stylesheet content.
* @param string $stylesheet_url Stylesheet URL.
*
* @return string
*/
public function rewrite_font_urls( $css, $stylesheet_url ) {
return preg_replace_callback(
'/url\((["\']?)([^)\'"]+)\1\)/i',
function ( $matches ) use ( $stylesheet_url ) {
$font_url = $this->absolute_url( trim( $matches[2] ), $stylesheet_url );
if ( '' === $font_url || ! $this->is_allowed_font_url( $font_url ) ) {
return $matches[0];
}
$local_url = $this->cache_font( $font_url );
if ( '' === $local_url ) {
return $matches[0];
}
return 'url(' . $matches[1] . $local_url . $matches[1] . ')';
},
$css
);
}
/**
* Cache a remote font file.
*
* @param string $font_url Remote font URL.
*
* @return string
*/
private function cache_font( $font_url ) {
$cache_dir = $this->cache_dir();
$cache_url = $this->cache_url();
if ( '' === $cache_dir || '' === $cache_url || ! $this->ensure_dir( $cache_dir ) ) {
return '';
}
$extension = $this->font_extension( $font_url );
$file = $cache_dir . md5( $font_url ) . '.' . $extension;
$url = $cache_url . basename( $file );
if ( $this->is_fresh( $file, $this->ttl() ) ) {
return $url;
}
$font = $this->remote_get_body( $font_url, self::FONT_MAX_BYTES );
if ( '' === $font || ! $this->write_file( $file, $font ) ) {
return file_exists( $file ) ? $url : '';
}
return $url;
}
/**
* Fetch a remote body from an allowed URL.
*
* @param string $url Remote URL.
* @param int $max_bytes Maximum response body size.
*
* @return string
*/
private function remote_get_body( $url, $max_bytes ) {
if ( ! function_exists( 'wp_remote_get' ) ) {
return '';
}
$failure_key = 'powered_cache_font_fetch_' . md5( $url );
if ( function_exists( 'get_transient' ) && get_transient( $failure_key ) ) {
return '';
}
$response = wp_remote_get(
$url,
array(
'timeout' => 5,
'redirection' => 2,
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
)
);
if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) {
$this->cache_fetch_failure( $failure_key );
return '';
}
$code = function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0;
if ( 200 > $code || 300 <= $code ) {
$this->cache_fetch_failure( $failure_key );
return '';
}
$body = function_exists( 'wp_remote_retrieve_body' ) ? wp_remote_retrieve_body( $response ) : '';
if ( ! is_string( $body ) || '' === $body || strlen( $body ) > $max_bytes ) {
$this->cache_fetch_failure( $failure_key );
return '';
}
if ( function_exists( 'delete_transient' ) ) {
delete_transient( $failure_key );
}
return $body;
}
/**
* Cache a short-lived remote fetch failure.
*
* @param string $key Failure cache key.
*
* @return void
*/
private function cache_fetch_failure( $key ) {
if ( function_exists( 'set_transient' ) ) {
set_transient( $key, 1, self::FAILURE_TTL );
}
}
/**
* Check whether a stylesheet URL is allowed.
*
* @param string $url URL to check.
*
* @return bool
*/
private function is_allowed_stylesheet_url( $url ) {
$host = parse_url( $url, PHP_URL_HOST );
$path = parse_url( $url, PHP_URL_PATH );
return in_array( $host, array( 'fonts.googleapis.com', 'fonts.bunny.net' ), true )
&& is_string( $path )
&& 0 === strpos( $path, '/css' );
}
/**
* Check whether a font URL is allowed.
*
* @param string $url URL to check.
*
* @return bool
*/
private function is_allowed_font_url( $url ) {
$host = parse_url( $url, PHP_URL_HOST );
return in_array( $host, array( 'fonts.gstatic.com', 'fonts.bunny.net' ), true )
&& in_array( $this->font_extension( $url ), array( 'woff2', 'woff', 'ttf', 'otf', 'eot' ), true );
}
/**
* Return a safe font extension from a URL.
*
* @param string $url Font URL.
*
* @return string
*/
private function font_extension( $url ) {
$path = (string) parse_url( $url, PHP_URL_PATH );
$extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
return in_array( $extension, array( 'woff2', 'woff', 'ttf', 'otf', 'eot' ), true ) ? $extension : 'woff2';
}
/**
* Convert a possibly relative URL to an absolute URL.
*
* @param string $url URL found in CSS.
* @param string $base Base stylesheet URL.
*
* @return string
*/
private function absolute_url( $url, $base ) {
if ( '' === $url || 0 === strpos( $url, 'data:' ) ) {
return '';
}
if ( 0 === strpos( $url, '//' ) ) {
return 'https:' . $url;
}
if ( preg_match( '#^https?://#i', $url ) ) {
return $url;
}
$scheme = parse_url( $base, PHP_URL_SCHEME );
$host = parse_url( $base, PHP_URL_HOST );
if ( ! $scheme || ! $host ) {
return '';
}
return $scheme . '://' . $host . '/' . ltrim( $url, '/' );
}
/**
* Normalize a URL for safe use.
*
* @param string $url URL.
*
* @return string
*/
private function normalize_url( $url ) {
$url = html_entity_decode( trim( (string) $url ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
if ( 0 === strpos( $url, '//' ) ) {
$url = 'https:' . $url;
}
return preg_match( '#^https?://#i', $url ) ? $url : '';
}
/**
* Normalize path separators.
*
* @param string $path Path.
*
* @return string
*/
private function normalize_path( $path ) {
return str_replace( '\\', '/', (string) $path );
}
/**
* Ensure a cache directory exists.
*
* @param string $dir Directory path.
*
* @return bool
*/
private function ensure_dir( $dir ) {
if ( is_dir( $dir ) ) {
return is_writable( $dir );
}
if ( function_exists( 'wp_mkdir_p' ) ) {
return wp_mkdir_p( $dir );
}
return mkdir( $dir, 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
}
/**
* Write cache content to disk.
*
* @param string $file File path.
* @param string $content File content.
*
* @return bool
*/
private function write_file( $file, $content ) {
return false !== file_put_contents( $file, $content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
}
/**
* Check whether a cache file is fresh.
*
* @param string $file File path.
* @param int $ttl Time to live in seconds.
*
* @return bool
*/
private function is_fresh( $file, $ttl ) {
return file_exists( $file ) && ( time() - filemtime( $file ) ) < $ttl;
}
/**
* Return cache TTL.
*
* @return int
*/
private function ttl() {
$month = defined( 'MONTH_IN_SECONDS' ) ? MONTH_IN_SECONDS : 2592000;
/**
* Filter local Google Fonts cache TTL.
*
* @hook powered_cache_google_fonts_cache_ttl
*
* @param {int} $ttl Cache lifetime in seconds.
*
* @return {int} New value.
* @since 4.0.0
*/
return max( 3600, (int) apply_filters( 'powered_cache_google_fonts_cache_ttl', $month ) );
}
}