PluginProbe
Jooosi Fon – Use Custom Fonts, Google Fonts or Adobe Fonts / trunk
Jooosi Fon – Use Custom Fonts, Google Fonts or Adobe Fonts vtrunk
1.1.2 1.1.3 1.1.0 1.1.1 trunk 1.0.100 1.0.114 1.0.30 1.0.31 1.0.32 1.0.33 1.0.34 1.0.35 1.0.36 1.0.37 1.0.38 1.0.39 1.0.40 1.0.41 1.0.42 1.0.45 1.0.46 1.0.47 1.0.48 1.0.49 All 74 releases
yabe-webfont / src / Core / Cache.php

Cache.php in Jooosi Fon – Use Custom Fonts, Google Fonts or Adobe Fonts trunk, at src/Core/Cache.php

420 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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