| 1 |
<?php |
| 2 |
|
| 3 |
/* |
| 4 |
* This file is part of the Jooosi Fon package. |
| 5 |
* |
| 6 |
* (c) Joshua Gugun Siagian <suabahasa@gmail.com> |
| 7 |
* |
| 8 |
* For the full copyright and license information, please view the LICENSE |
| 9 |
* file that was distributed with this source code. |
| 10 |
*/ |
| 11 |
declare (strict_types=1); |
| 12 |
namespace JooosiFon\Core; |
| 13 |
|
| 14 |
use JooosiFonDeps\JOOOSI_FON; |
| 15 |
use JooosiFon\Core\Cache\FontCacheSnapshotBuilder; |
| 16 |
use JooosiFon\Core\Cache\FontCssRenderer; |
| 17 |
use JooosiFon\Core\Cache\FontPreloadRenderer; |
| 18 |
use JooosiFon\Upgrade\LegacyRebrandUpgrade; |
| 19 |
use JooosiFon\Utils\Common; |
| 20 |
use JooosiFon\Utils\Config; |
| 21 |
use JooosiFon\Utils\Notice; |
| 22 |
/** |
| 23 |
* Manage the cache of fonts for the frontpage. |
| 24 |
* |
| 25 |
* @author Joshua Gugun Siagian <suabahasa@gmail.com> |
| 26 |
* @todo Remove all legacy Yabe Webfont hook shims completely in Jooosi Fon 3.0.0. |
| 27 |
*/ |
| 28 |
class Cache |
| 29 |
{ |
| 30 |
/** |
| 31 |
* @var string |
| 32 |
*/ |
| 33 |
public const CSS_CACHE_FILE = 'fonts.css'; |
| 34 |
/** |
| 35 |
* @var string |
| 36 |
*/ |
| 37 |
public const PRELOAD_HTML_FILE = 'preload.html'; |
| 38 |
/** |
| 39 |
* @var string |
| 40 |
*/ |
| 41 |
public const CACHE_DIR = '/jooosi-fon/cache/'; |
| 42 |
/** |
| 43 |
* @var string |
| 44 |
*/ |
| 45 |
public static $typekit_embed = 'css'; |
| 46 |
public function __construct() |
| 47 |
{ |
| 48 |
$this->migrate_legacy_scheduled_cache(); |
| 49 |
add_action('a!jooosi/fon/core/cache:build_cache', fn() => $this->build_cache()); |
| 50 |
// listen to fonts event for cache build (async/scheduled) |
| 51 |
add_action('a!jooosi/fon/api/font:fonts_event_async', fn() => $this->schedule_cache(), 10, 1); |
| 52 |
// listen to fonts event for cache build (sync) |
| 53 |
add_action('a!jooosi/fon/api/font:fonts_event', fn() => $this->build_cache(), 10, 1); |
| 54 |
// listen to theme switch for cache build (async/scheduled) |
| 55 |
add_action('switch_theme', fn() => $this->schedule_cache(), 1000001); |
| 56 |
// listen to Config change for cache build (async/scheduled) |
| 57 |
add_action('f!jooosi/fon/api/setting/option:after_store', fn() => $this->schedule_cache(), 10, 1); |
| 58 |
// listen to plugin upgrade for cache build (async/scheduled) |
| 59 |
add_action('a!jooosi/fon/plugins:upgrade_plugin_end', fn() => $this->schedule_cache(), 10, 1); |
| 60 |
$this->maybeRebuildAfterLegacyUpgrade(); |
| 61 |
} |
| 62 |
public function schedule_cache(): void |
| 63 |
{ |
| 64 |
self::updateBuildStatus(['state' => 'pending', 'requested_at' => time(), 'changed' => \false, 'warnings' => [], 'error' => '']); |
| 65 |
if (!wp_next_scheduled('a!jooosi/fon/core/cache:build_cache')) { |
| 66 |
$scheduled = wp_schedule_single_event(time() + 10, 'a!jooosi/fon/core/cache:build_cache'); |
| 67 |
if ($scheduled === \false) { |
| 68 |
self::updateBuildStatus(['state' => 'failed', 'error' => 'The cache build could not be scheduled.', 'completed_at' => time()]); |
| 69 |
} |
| 70 |
} |
| 71 |
} |
| 72 |
public static function get_cache_path(string $file_path = ''): string |
| 73 |
{ |
| 74 |
return wp_upload_dir()['basedir'] . self::CACHE_DIR . $file_path; |
| 75 |
} |
| 76 |
public static function get_cache_url(string $file_path = ''): string |
| 77 |
{ |
| 78 |
return wp_upload_dir()['baseurl'] . self::CACHE_DIR . $file_path; |
| 79 |
} |
| 80 |
public static function get_versioned_cache_url(string $filePath): string |
| 81 |
{ |
| 82 |
$url = self::get_cache_url($filePath); |
| 83 |
$version = self::get_cache_version($filePath); |
| 84 |
return $version !== null ? add_query_arg('ver', $version, $url) : $url; |
| 85 |
} |
| 86 |
public static function get_cache_version(string $filePath): ?string |
| 87 |
{ |
| 88 |
$path = self::get_cache_path($filePath); |
| 89 |
if (!is_readable($path)) { |
| 90 |
return null; |
| 91 |
} |
| 92 |
$hash = hash_file('sha256', $path); |
| 93 |
return is_string($hash) ? substr($hash, 0, 16) : null; |
| 94 |
} |
| 95 |
public function build_cache(): void |
| 96 |
{ |
| 97 |
try { |
| 98 |
$lock = $this->acquireBuildLock(); |
| 99 |
} catch (\Throwable $throwable) { |
| 100 |
$message = sprintf('Failed to start the font cache build: %s', $throwable->getMessage()); |
| 101 |
self::updateBuildStatus(['state' => 'failed', 'completed_at' => time(), 'changed' => \false, 'warnings' => [], 'error' => $message]); |
| 102 |
Notice::error($message, 'jooosi-fon-cache-build', \true); |
| 103 |
return; |
| 104 |
} |
| 105 |
if ($lock === \false) { |
| 106 |
return; |
| 107 |
} |
| 108 |
self::updateBuildStatus(['state' => 'running', 'started_at' => time(), 'changed' => \false, 'warnings' => [], 'error' => '']); |
| 109 |
try { |
| 110 |
$artifacts = self::buildArtifacts(); |
| 111 |
$payload = sprintf("/*\n! %s v%s | %s\n*/\n\n%s", Common::plugin_data('Name'), JOOOSI_FON::VERSION, date('Y-m-d H:i:s', time()), $artifacts['css']); |
| 112 |
$cssPath = self::get_cache_path(self::CSS_CACHE_FILE); |
| 113 |
$preloadPath = self::get_cache_path(self::PRELOAD_HTML_FILE); |
| 114 |
self::publishArtifacts($payload, $artifacts['preload'], $cssPath, $preloadPath); |
| 115 |
$this->purge_cache_plugin(); |
| 116 |
self::updateBuildStatus(['state' => 'succeeded', 'completed_at' => time(), 'hash' => hash('sha256', $payload . "\x00" . $artifacts['preload']), 'changed' => \true, 'warnings' => $artifacts['warnings'], 'error' => '']); |
| 117 |
do_action('a!jooosi/fon/core/cache:build_succeeded', \true, $artifacts['warnings']); |
| 118 |
} catch (\Throwable $throwable) { |
| 119 |
$message = sprintf('Failed to build the font cache: %s', $throwable->getMessage()); |
| 120 |
self::updateBuildStatus(['state' => 'failed', 'completed_at' => time(), 'changed' => \false, 'warnings' => [], 'error' => $message]); |
| 121 |
Notice::error($message, 'jooosi-fon-cache-build', \true); |
| 122 |
do_action('a!jooosi/fon/core/cache:build_failed', $throwable); |
| 123 |
} finally { |
| 124 |
flock($lock, \LOCK_UN); |
| 125 |
fclose($lock); |
| 126 |
} |
| 127 |
} |
| 128 |
public static function build_css(): string |
| 129 |
{ |
| 130 |
return self::buildArtifacts()['css']; |
| 131 |
} |
| 132 |
public static function build_preload(): string |
| 133 |
{ |
| 134 |
return self::buildArtifacts()['preload']; |
| 135 |
} |
| 136 |
public static function get_kit_css($kit_id, ?array &$warnings = null): string |
| 137 |
{ |
| 138 |
$kitId = trim((string) $kit_id); |
| 139 |
if (preg_match('/^[a-zA-Z0-9_-]{1,100}$/', $kitId) !== 1) { |
| 140 |
self::adobeWarning($warnings, 'Adobe Fonts CSS was skipped because the project ID is invalid.'); |
| 141 |
return ''; |
| 142 |
} |
| 143 |
$cachePath = self::get_cache_path('adobe/' . hash('sha256', $kitId) . '.css'); |
| 144 |
$cachedCss = is_readable($cachePath) ? file_get_contents($cachePath) : \false; |
| 145 |
$cachedCss = is_string($cachedCss) ? $cachedCss : ''; |
| 146 |
if (!self::isSafeStylesheet($cachedCss)) { |
| 147 |
$cachedCss = ''; |
| 148 |
} |
| 149 |
$ttl = max(300, (int) apply_filters('f!jooosi/fon/core/cache:adobe_css_ttl', 12 * \HOUR_IN_SECONDS)); |
| 150 |
if ($cachedCss !== '' && filemtime($cachePath) > time() - $ttl) { |
| 151 |
return $cachedCss; |
| 152 |
} |
| 153 |
$response = wp_safe_remote_get(sprintf('https://use.typekit.net/%s.css', rawurlencode($kitId)), ['timeout' => 15, 'redirection' => 2, 'limit_response_size' => 2 * \MB_IN_BYTES]); |
| 154 |
if (is_wp_error($response)) { |
| 155 |
return self::adobeFallback($cachedCss, $warnings, 'Adobe Fonts could not be refreshed.'); |
| 156 |
} |
| 157 |
$status = wp_remote_retrieve_response_code($response); |
| 158 |
if ($status === 412) { |
| 159 |
self::$typekit_embed = 'js'; |
| 160 |
return ''; |
| 161 |
} |
| 162 |
if ($status !== 200) { |
| 163 |
return self::adobeFallback($cachedCss, $warnings, sprintf('Adobe Fonts returned HTTP %d.', $status)); |
| 164 |
} |
| 165 |
$body = wp_remote_retrieve_body($response); |
| 166 |
if (!is_string($body) || !self::isSafeStylesheet($body)) { |
| 167 |
return self::adobeFallback($cachedCss, $warnings, 'Adobe Fonts returned an invalid stylesheet.'); |
| 168 |
} |
| 169 |
try { |
| 170 |
self::publishArtifact($body, $cachePath); |
| 171 |
} catch (\Throwable $throwable) { |
| 172 |
self::adobeWarning($warnings, 'Adobe Fonts CSS was refreshed but could not be cached separately.'); |
| 173 |
} |
| 174 |
return $body; |
| 175 |
} |
| 176 |
public static function get_kit_js($kit_id): string |
| 177 |
{ |
| 178 |
$kitId = trim((string) $kit_id); |
| 179 |
if (preg_match('/^[a-zA-Z0-9_-]{1,100}$/', $kitId) !== 1) { |
| 180 |
return ''; |
| 181 |
} |
| 182 |
$response = wp_safe_remote_get(sprintf('https://use.typekit.net/%s.js', rawurlencode($kitId)), ['timeout' => 15, 'redirection' => 2, 'limit_response_size' => 2 * \MB_IN_BYTES]); |
| 183 |
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { |
| 184 |
return ''; |
| 185 |
} |
| 186 |
$body = wp_remote_retrieve_body($response); |
| 187 |
if (!is_string($body) || trim($body) === '') { |
| 188 |
return ''; |
| 189 |
} |
| 190 |
$js = '<script type="text/javascript">'; |
| 191 |
$js .= $body; |
| 192 |
$js .= "\n\n"; |
| 193 |
$js .= sprintf('try{Typekit.load({kitId:%s,scriptTimeout:3E3,async:true});}catch(e){}', wp_json_encode($kitId)); |
| 194 |
$js .= '</script>'; |
| 195 |
return "\n\n" . $js . "\n\n"; |
| 196 |
} |
| 197 |
/** |
| 198 |
* @return array<string, mixed> |
| 199 |
*/ |
| 200 |
public static function getBuildStatus(): array |
| 201 |
{ |
| 202 |
$status = get_option(JOOOSI_FON::WP_OPTION . '_cache_build_status', []); |
| 203 |
return is_array($status) ? $status : []; |
| 204 |
} |
| 205 |
/** |
| 206 |
* @return array{css: string, preload: string, warnings: string[]} |
| 207 |
*/ |
| 208 |
private static function buildArtifacts(): array |
| 209 |
{ |
| 210 |
self::$typekit_embed = 'css'; |
| 211 |
$snapshot = (new FontCacheSnapshotBuilder())->build(); |
| 212 |
$warnings = $snapshot['warnings']; |
| 213 |
$adobeCss = ''; |
| 214 |
$projectId = trim((string) Config::get('adobe_fonts.project_id', '')); |
| 215 |
$hasAdobeFonts = in_array('adobe-fonts', array_column($snapshot['fonts'], 'type'), \true); |
| 216 |
if ($projectId !== '' && $hasAdobeFonts) { |
| 217 |
$adobeCss = self::get_kit_css($projectId, $warnings); |
| 218 |
} |
| 219 |
$css = (new FontCssRenderer())->render($snapshot['fonts'], $adobeCss); |
| 220 |
/** |
| 221 |
* @param string $css The CSS content |
| 222 |
* @param array $rows The active database rows |
| 223 |
* @return string The CSS content |
| 224 |
*/ |
| 225 |
$css = apply_filters('f!jooosi/fon/core/cache:build_css.append_content', $css, $snapshot['rows']); |
| 226 |
$css = apply_filters_deprecated('f!yabe/webfont/core/cache:build_css.append_content', [$css, $snapshot['rows']], '2.1.0', 'f!jooosi/fon/core/cache:build_css.append_content'); |
| 227 |
if (!is_string($css)) { |
| 228 |
throw new \UnexpectedValueException('The font CSS filter must return a string.'); |
| 229 |
} |
| 230 |
$preloadLimit = (int) apply_filters('f!jooosi/fon/core/cache:preload_limit', 6); |
| 231 |
$preload = (new FontPreloadRenderer())->render($snapshot['fonts'], $preloadLimit); |
| 232 |
if (self::$typekit_embed === 'js' && $projectId !== '' && $hasAdobeFonts) { |
| 233 |
$preload .= self::get_kit_js($projectId); |
| 234 |
} |
| 235 |
foreach ($warnings as $warning) { |
| 236 |
do_action('a!jooosi/fon/core/cache:build_warning', $warning); |
| 237 |
} |
| 238 |
return ['css' => $css, 'preload' => $preload, 'warnings' => array_values(array_unique($warnings))]; |
| 239 |
} |
| 240 |
/** |
| 241 |
* @return resource|false |
| 242 |
*/ |
| 243 |
private function acquireBuildLock() |
| 244 |
{ |
| 245 |
$directory = self::get_cache_path(); |
| 246 |
if (!is_dir($directory) && !wp_mkdir_p($directory)) { |
| 247 |
throw new \RuntimeException('The font cache directory could not be created.'); |
| 248 |
} |
| 249 |
$lock = fopen($directory . '.build.lock', 'c'); |
| 250 |
if ($lock === \false) { |
| 251 |
throw new \RuntimeException('The font cache build lock could not be opened.'); |
| 252 |
} |
| 253 |
if (!flock($lock, \LOCK_EX | \LOCK_NB)) { |
| 254 |
fclose($lock); |
| 255 |
return \false; |
| 256 |
} |
| 257 |
return $lock; |
| 258 |
} |
| 259 |
private static function publishArtifacts(string $css, string $preload, string $cssPath, string $preloadPath): void |
| 260 |
{ |
| 261 |
$cssTemp = self::stageArtifact($css, $cssPath); |
| 262 |
$preloadTemp = null; |
| 263 |
try { |
| 264 |
$preloadTemp = self::stageArtifact($preload, $preloadPath); |
| 265 |
if (!rename($preloadTemp, $preloadPath)) { |
| 266 |
throw new \RuntimeException('The font preload cache could not be published.'); |
| 267 |
} |
| 268 |
$preloadTemp = null; |
| 269 |
if (!rename($cssTemp, $cssPath)) { |
| 270 |
throw new \RuntimeException('The font CSS cache could not be published.'); |
| 271 |
} |
| 272 |
$cssTemp = null; |
| 273 |
} finally { |
| 274 |
if (is_string($cssTemp) && file_exists($cssTemp)) { |
| 275 |
unlink($cssTemp); |
| 276 |
} |
| 277 |
if (is_string($preloadTemp) && file_exists($preloadTemp)) { |
| 278 |
unlink($preloadTemp); |
| 279 |
} |
| 280 |
} |
| 281 |
} |
| 282 |
private static function publishArtifact(string $contents, string $path): void |
| 283 |
{ |
| 284 |
$temporaryPath = self::stageArtifact($contents, $path); |
| 285 |
try { |
| 286 |
if (!rename($temporaryPath, $path)) { |
| 287 |
throw new \RuntimeException('The cache artifact could not be published.'); |
| 288 |
} |
| 289 |
$temporaryPath = null; |
| 290 |
} finally { |
| 291 |
if (is_string($temporaryPath) && file_exists($temporaryPath)) { |
| 292 |
unlink($temporaryPath); |
| 293 |
} |
| 294 |
} |
| 295 |
} |
| 296 |
private static function stageArtifact(string $contents, string $path): string |
| 297 |
{ |
| 298 |
$directory = dirname($path); |
| 299 |
if (!is_dir($directory) && !wp_mkdir_p($directory)) { |
| 300 |
throw new \RuntimeException('The cache artifact directory could not be created.'); |
| 301 |
} |
| 302 |
$temporaryPath = tempnam($directory, '.jooosi-fon-'); |
| 303 |
if (!is_string($temporaryPath)) { |
| 304 |
throw new \RuntimeException('A temporary cache artifact could not be created.'); |
| 305 |
} |
| 306 |
$written = file_put_contents($temporaryPath, $contents, \LOCK_EX); |
| 307 |
if ($written === \false || $written !== strlen($contents)) { |
| 308 |
unlink($temporaryPath); |
| 309 |
throw new \RuntimeException('A cache artifact could not be written completely.'); |
| 310 |
} |
| 311 |
$permissions = defined('FS_CHMOD_FILE') ? \FS_CHMOD_FILE : 0644; |
| 312 |
chmod($temporaryPath, $permissions); |
| 313 |
return $temporaryPath; |
| 314 |
} |
| 315 |
/** |
| 316 |
* @param array<string, mixed> $status |
| 317 |
*/ |
| 318 |
private static function updateBuildStatus(array $status): void |
| 319 |
{ |
| 320 |
update_option(JOOOSI_FON::WP_OPTION . '_cache_build_status', array_merge(self::getBuildStatus(), $status), \false); |
| 321 |
} |
| 322 |
private static function adobeWarning(?array &$warnings, string $message): void |
| 323 |
{ |
| 324 |
if ($warnings !== null && count($warnings) < 100) { |
| 325 |
$warnings[] = $message; |
| 326 |
} |
| 327 |
} |
| 328 |
private static function adobeFallback(string $cachedCss, ?array &$warnings, string $message): string |
| 329 |
{ |
| 330 |
if ($cachedCss === '') { |
| 331 |
throw new \RuntimeException($message . ' No cached stylesheet is available.'); |
| 332 |
} |
| 333 |
self::adobeWarning($warnings, $message . ' The last-known-good stylesheet was retained.'); |
| 334 |
return $cachedCss; |
| 335 |
} |
| 336 |
private static function isSafeStylesheet(string $css): bool |
| 337 |
{ |
| 338 |
return trim($css) !== '' && stripos($css, '</style') === \false; |
| 339 |
} |
| 340 |
/** |
| 341 |
* @todo Remove this legacy cron migration completely in Jooosi Fon 3.0.0. |
| 342 |
*/ |
| 343 |
private function migrate_legacy_scheduled_cache(): void |
| 344 |
{ |
| 345 |
$legacyHook = 'a!yabe/webfont/core/cache:build_cache'; |
| 346 |
$hook = 'a!jooosi/fon/core/cache:build_cache'; |
| 347 |
$legacyTimestamp = wp_next_scheduled($legacyHook); |
| 348 |
if ($legacyTimestamp === \false) { |
| 349 |
return; |
| 350 |
} |
| 351 |
if (!wp_next_scheduled($hook)) { |
| 352 |
wp_schedule_single_event(max(time() + 1, $legacyTimestamp), $hook); |
| 353 |
} |
| 354 |
wp_clear_scheduled_hook($legacyHook); |
| 355 |
} |
| 356 |
/** |
| 357 |
* Rebuild generated files immediately after upload paths have moved so a |
| 358 |
* frontend request never prints cache content containing legacy URLs. |
| 359 |
* |
| 360 |
* @todo Remove this legacy cache rebuild completely in Jooosi Fon 3.0.0. |
| 361 |
*/ |
| 362 |
private function maybeRebuildAfterLegacyUpgrade(): void |
| 363 |
{ |
| 364 |
if (get_option(LegacyRebrandUpgrade::CACHE_REBUILD_OPTION, \false) === \false) { |
| 365 |
return; |
| 366 |
} |
| 367 |
self::updateBuildStatus(['state' => 'pending', 'requested_at' => time(), 'changed' => \false, 'warnings' => [], 'error' => '']); |
| 368 |
$this->build_cache(); |
| 369 |
if ((self::getBuildStatus()['state'] ?? null) === 'succeeded') { |
| 370 |
delete_option(LegacyRebrandUpgrade::CACHE_REBUILD_OPTION); |
| 371 |
} |
| 372 |
} |
| 373 |
/** |
| 374 |
* Clear the cache from various cache plugins. |
| 375 |
*/ |
| 376 |
private function purge_cache_plugin() |
| 377 |
{ |
| 378 |
/** |
| 379 |
* Only invalidate this plugin's font catalog. Flushing the complete |
| 380 |
* WordPress object cache can create a site-wide performance spike. |
| 381 |
* @see https://developer.wordpress.org/reference/classes/wp_object_cache/ |
| 382 |
*/ |
| 383 |
\wp_cache_delete('get_fonts', JOOOSI_FON::WP_OPTION); |
| 384 |
do_action('a!jooosi/fon/core/cache:before_page_cache_purge'); |
| 385 |
/** |
| 386 |
* WP Rocket |
| 387 |
* @see https://docs.wp-rocket.me/article/92-rocketcleandomain |
| 388 |
*/ |
| 389 |
if (function_exists('rocket_clean_domain')) { |
| 390 |
\rocket_clean_domain(); |
| 391 |
} |
| 392 |
/** |
| 393 |
* WP Super Cache |
| 394 |
* @see https://github.com/Automattic/wp-super-cache/blob/a0872032b1b3fc6847f490eadfabf74c12ad0135/wp-cache-phase2.php#L3013 |
| 395 |
*/ |
| 396 |
if (function_exists('wp_cache_clear_cache')) { |
| 397 |
\wp_cache_clear_cache(); |
| 398 |
} |
| 399 |
/** |
| 400 |
* W3 Total Cache |
| 401 |
* @see https://github.com/BoldGrid/w3-total-cache/blob/3a094493064ea60d727b3389dee813639860ef49/w3-total-cache-api.php#L259 |
| 402 |
*/ |
| 403 |
if (function_exists('w3tc_flush_all')) { |
| 404 |
\w3tc_flush_all(); |
| 405 |
} |
| 406 |
/** |
| 407 |
* WP Fastest Cache |
| 408 |
* @see https://www.wpfastestcache.com/tutorial/delete-the-cache-by-calling-the-function/ |
| 409 |
*/ |
| 410 |
if (function_exists('wpfc_clear_all_cache')) { |
| 411 |
\wpfc_clear_all_cache(\true); |
| 412 |
} |
| 413 |
/** |
| 414 |
* LiteSpeed Cache |
| 415 |
* @see https://docs.litespeedtech.com/lscache/lscwp/api/#purge-all-existing-caches |
| 416 |
*/ |
| 417 |
do_action('litespeed_purge_all'); |
| 418 |
} |
| 419 |
} |
| 420 |
|