PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
← All changes | src/Detect/Consolidator.php +423 -77 2.0.0 → 2.0.4 View file →
@@ -92,8 +92,93 @@
92 92 */
93 93 private array $contributed = array();
94 94
95 95 /**
96 + * Set true when an enabled variant the page needs is NOT yet cached and we
97 + * couldn't download it on this request (visitor render = downloads disabled,
98 + * or warm-budget exhausted). When true, run() leaves the page untouched and
99 + * signals a background warm so the cache fills off the render path.
100 + *
101 + * @var bool
102 + */
103 + private bool $missing = false;
104 +
105 + /**
106 + * Number of local/external stylesheets rewritten in place to cleaned copies
107 + * this request (independent of the consolidated provider stylesheet).
108 + *
109 + * @var int
110 + */
111 + private int $local_rewrites = 0;
112 +
113 + /**
114 + * Font binaries downloaded so far this request (warm-budget counter).
115 + *
116 + * @var int
117 + */
118 + private int $dl_count = 0;
119 +
120 + /**
121 + * Monotonic start time of the first download this request (warm budget).
122 + *
123 + * @var float
124 + */
125 + private float $dl_started = 0.0;
126 +
127 + /**
128 + * Lowercased families the beacon has CONFIRMED as loaded-but-never-rendered
129 + * on the current route — the only families page scoping drops. A font that
130 + * hasn't been measured yet (newly declared, just added/replaced) is NOT in
131 + * here, so it always loads until a beacon has judged it. See set_page_scope().
132 + *
133 + * @var array<string,bool>
134 + */
135 + private array $scope_out = array();
136 +
137 + /**
138 + * Whether the current route has been measured by a beacon. When false we
139 + * never scope (the scope-out set is empty anyway); kept so the page-scope
140 + * feature can be filtered off wholesale.
141 + *
142 + * @var bool
143 + */
144 + private bool $route_measured = false;
145 +
146 + /**
147 + * Provide the per-route scope-out set before run(). A declared font whose
148 + * family is listed here is left out of THIS page's stylesheet (the beacon
149 + * measured it loaded but it never rendered here). The font stays registered
150 + * and is still hosted for the routes that use it.
151 + *
152 + * @param array<string,bool> $scope_out Lowercased family => true.
153 + * @param bool $measured Route has beacon data.
154 + * @return void
155 + */
156 + public function set_page_scope( array $scope_out, bool $measured ): void {
157 + $this->scope_out = $scope_out;
158 + $this->route_measured = $measured;
159 + }
160 +
161 + /**
162 + * True when the most recent run found enabled fonts that aren't cached yet
163 + * and downloads were disabled (visitor render) — i.e. a background warm is
164 + * needed. Read by the output buffer to spawn a non-blocking loopback.
165 + *
166 + * @var bool
167 + */
168 + private static bool $needs_warm = false;
169 +
170 + /**
171 + * True when the most recent run actually committed a localization (a
172 + * consolidated stylesheet and/or an in-place local stylesheet rewrite). The
173 + * output buffer uses this to decide whether to inject head markup / strip
174 + * resource hints.
175 + *
176 + * @var bool
177 + */
178 + private static bool $localized = false;
179 +
180 + /**
96 181 * Variants hosted this request (for fallbacks + usage logging).
97 182 *
98 183 * @var array<int,array{family:string,weight:string,style:string}>
99 184 */
@@ -123,8 +208,29 @@
123 208 return self::$touched;
124 209 }
125 210
126 211 /**
212 + * Does the current page need a background cache-warm? True when an enabled
213 + * font isn't cached yet and downloads were disabled for this (visitor)
214 + * render.
215 + *
216 + * @return bool
217 + */
218 + public static function needs_warm(): bool {
219 + return self::$needs_warm;
220 + }
221 +
222 + /**
223 + * Did the most recent run commit a localization (so the output buffer should
224 + * inject preloads/fallbacks and strip provider hints)?
225 + *
226 + * @return bool
227 + */
228 + public static function localized(): bool {
229 + return self::$localized;
230 + }
231 +
232 + /**
127 233 * Consolidated stylesheet URL from the most recent run.
128 234 *
129 235 * @return string
130 236 */
@@ -156,8 +262,16 @@
156 262
157 263 /**
158 264 * Run the full consolidation pass over a document.
159 265 *
266 + * Transactional + fail-safe: detection/rewriting happens on a working copy.
267 + * The rewritten page is only returned ("committed") when EVERY enabled font
268 + * it needs is already served locally. If any needed font isn't cached yet and
269 + * downloads are disabled for this request (a normal visitor render never
270 + * downloads — that's what used to stall and white-screen the site), the
271 + * ORIGINAL page is returned unchanged (so the original Google Fonts keep
272 + * working — no FOUC, no missing text) and a background warm is flagged.
273 + *
160 274 * @param string $html Full HTML.
161 275 * @param bool $force Reserved (probe mode); detection is identical.
162 276 * @return string
163 277 */
@@ -163,50 +277,134 @@
163 277 */
164 278 public function run( string $html, bool $force = false ): string {
165 279 unset( $force );
166 280
281 + $original = $html;
282 +
167 283 self::$stylesheet_url = '';
168 284 self::$stylesheet_file = '';
285 + self::$needs_warm = false;
286 + self::$localized = false;
287 + $this->missing = false;
288 + $this->local_rewrites = 0;
169 289
170 - // 1. COLLECT (each step may rewrite the HTML — e.g. strip inline blocks).
171 - $html = $this->collect_from_links( $html );
172 - $html = $this->collect_from_inline( $html );
173 - $html = $this->collect_from_webfont( $html );
290 + // 1. COLLECT (each step may rewrite the working HTML — e.g. strip inline
291 + // blocks, or rewrite a theme stylesheet to a cleaned local copy). We
292 + // keep the untouched $original to fall back to.
293 + $working = $this->collect_from_links( $html );
294 + $working = $this->collect_from_inline( $working );
295 + $working = $this->collect_from_webfont( $working );
174 296 $this->collect_async_urls();
175 297
176 - // Physically remove the provider <link> tags we flagged.
177 - $html = $this->html->remove_stylesheet_links( $html, array_values( array_unique( $this->remove_links ) ) );
298 + // Backstop: nothing in collection may ever blank the document. If a step
299 + // somehow emptied a non-empty page, discard the working copy entirely.
300 + if ( '' === $working && '' !== $original ) {
301 + return $original;
302 + }
178 303
179 - if ( empty( $this->css_urls ) && empty( $this->raw_blocks ) ) {
180 - return $html;
304 + $has_provider = ! empty( $this->css_urls ) || ! empty( $this->raw_blocks );
305 +
306 + // Nothing font-related found at all → return the page untouched.
307 + if ( ! $has_provider && 0 === $this->local_rewrites ) {
308 + return $original;
181 309 }
182 310
183 - // 2. AGGREGATE.
184 - $variants = $this->aggregate();
311 + // 2. AGGREGATE + 3. HOST the consolidated (provider <link> / inline) set.
312 + // Downloads only happen here when allowed (admin/CLI/warm); on a
313 + // visitor render the missing flag is set instead.
314 + $file_url = null;
185 315
186 - if ( empty( $variants ) ) {
187 - return $html;
316 + if ( $has_provider ) {
317 + $variants = $this->aggregate();
318 +
319 + if ( ! empty( $variants ) ) {
320 + $file_url = $this->host( $variants );
321 + }
188 322 }
189 323
190 - // 3. HOST (register + build the consolidated stylesheet).
191 - $file_url = $this->host( $variants );
324 + // If ANY enabled font the page needs isn't served locally yet, don't touch
325 + // the page — keep the originals working and warm the cache in background.
326 + if ( $this->missing ) {
327 + return $this->bail( $original );
328 + }
192 329
193 - if ( null === $file_url ) {
194 - return $html;
330 + // Nothing actually localized (no consolidated sheet built and no local
331 + // stylesheet rewritten) → return the page untouched.
332 + if ( null === $file_url && 0 === $this->local_rewrites ) {
333 + return $this->bail( $original );
195 334 }
196 335
197 - // Record for the output buffer, which injects preloads + the stylesheet
198 - // + fallbacks together in the correct order. (We don't inject here so the
199 - // preloads can be positioned ABOVE the stylesheet.)
200 - self::$stylesheet_url = $file_url;
336 + // Everything needed is hosted locally. Physically remove the provider
337 + // <link> tags we flagged — but only when we built a consolidated
338 + // replacement for them (otherwise leave them, so we never drop fonts).
339 + if ( null !== $file_url ) {
340 + $working = $this->html->remove_stylesheet_links( $working, array_values( array_unique( $this->remove_links ) ) );
341 + self::$stylesheet_url = $file_url;
342 + }
201 343
344 + self::$localized = true;
345 +
202 346 foreach ( array_keys( $this->contributed ) as $id ) {
203 347 Settings::learn_detector( $id );
204 348 }
205 349
206 - return $html;
350 + return $working;
207 351 }
208 352
353 + /**
354 + * Abort the rewrite: clear any partial output state and return the page
355 + * exactly as it came in. If fonts are missing only because downloads were
356 + * disabled (visitor render), flag a background warm.
357 + *
358 + * @param string $original Untouched HTML.
359 + * @return string
360 + */
361 + private function bail( string $original ): string {
362 + self::$stylesheet_url = '';
363 + self::$stylesheet_file = '';
364 + self::$touched = array();
365 +
366 + if ( $this->missing && ! Downloader::fonts_allowed() ) {
367 + self::$needs_warm = true;
368 + }
369 +
370 + return $original;
371 + }
372 +
373 + /**
374 + * Warm-download budget: bounds how much a single warm (probe) request will
375 + * fetch, so it can't run long enough to hit max_execution_time. Anything left
376 + * over is fetched by the next warm. Always true when downloads are disabled
377 + * (the caller handles that case via the missing flag).
378 + *
379 + * @return bool
380 + */
381 + private function within_budget(): bool {
382 + if ( 0.0 === $this->dl_started ) {
383 + $this->dl_started = microtime( true );
384 + }
385 +
386 + /**
387 + * Max font binaries to download in a single warm request.
388 + *
389 + * @param int $max
390 + */
391 + $max_n = (int) apply_filters( 'easyfonts_warm_max_downloads', 25 );
392 +
393 + /**
394 + * Max wall-clock seconds to spend downloading in a single warm request.
395 + *
396 + * @param float $max
397 + */
398 + $max_s = (float) apply_filters( 'easyfonts_warm_max_seconds', 25.0 );
399 +
400 + if ( $this->dl_count >= $max_n ) {
401 + return false;
402 + }
403 +
404 + return ( microtime( true ) - $this->dl_started ) < $max_s;
405 + }
406 +
209 407 /* --------------------------------------------------------------------- *
210 408 * 1. COLLECT
211 409 * --------------------------------------------------------------------- */
212 410
@@ -266,8 +464,9 @@
266 464 return null;
267 465 }
268 466
269 467 $this->mark( $detector );
468 + $this->local_rewrites++;
270 469
271 470 return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $filename ) );
272 471 }
273 472 );
@@ -289,9 +488,9 @@
289 488 function ( $css ) {
290 489 $original = $css;
291 490
292 491 // Provider @import → collect + remove the statement.
293 - $css = (string) preg_replace_callback(
492 + $replaced = preg_replace_callback(
294 493 '/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s]+)[\'"]?\s*\)?\s*;?/i',
295 494 function ( $m ) {
296 495 $url = Providers::normalize_url( $m[1] );
297 496
@@ -303,8 +502,9 @@
303 502 return $m[0];
304 503 },
305 504 $css
306 505 );
506 + $css = is_string( $replaced ) ? $replaced : $original;
307 507
308 508 // Remote @font-face → collect raw block + remove it.
309 509 $blocks = $this->parser->blocks( $css );
310 510
@@ -398,16 +598,56 @@
398 598
399 599 $this->add_css_url( $url, 'webfont' );
400 600 $this->mark( 'webfont' );
401 601
402 - // Remove webfont.js loader scripts + the WebFontConfig block.
403 - $html = (string) preg_replace( '#<script\b[^>]*\bsrc=[\'"][^\'"]*webfont(?:\.min)?\.js[\'"][^>]*>\s*</script>#i', '', $html );
404 - $html = (string) preg_replace( '#<script\b[^>]*>(?:(?!</script>).)*?WebFontConfig(?:(?!</script>).)*?</script>#is', '', $html );
602 + // Remove webfont.js loader scripts + the WebFontConfig block. The src
603 + // pattern uses bounded character classes (safe); null-guard it anyway so a
604 + // PCRE failure can never blank the page. The WebFontConfig block is removed
605 + // by a linear scan instead of a tempered-dot regex — that regex hit the
606 + // PCRE2 JIT stack limit on ordinary inline scripts and blanked the page.
607 + $stripped = preg_replace( '#<script\b[^>]*\bsrc=[\'"][^\'"]*webfont(?:\.min)?\.js[\'"][^>]*>\s*</script>#i', '', $html );
608 + $html = is_string( $stripped ) ? $stripped : $html;
609 + $html = $this->remove_script_block_containing( $html, 'WebFontConfig' );
405 610
406 611 return $html;
407 612 }
408 613
409 614 /**
615 + * Remove any <script>…</script> block whose contents contain $needle, using a
616 + * linear strpos scan (no regex → no catastrophic backtracking / JIT crash).
617 + *
618 + * @param string $html HTML.
619 + * @param string $needle Marker that must appear inside the script.
620 + * @return string
621 + */
622 + private function remove_script_block_containing( string $html, string $needle ): string {
623 + $offset = 0;
624 +
625 + while ( true ) {
626 + $pos = stripos( $html, $needle, $offset );
627 +
628 + if ( false === $pos ) {
629 + break;
630 + }
631 +
632 + $start = strripos( substr( $html, 0, $pos ), '<script' );
633 + $gt = false === $start ? false : strpos( $html, '>', $start );
634 + $end = stripos( $html, '</script>', $pos );
635 +
636 + // Marker isn't inside a clean <script>…</script> wrapper → skip past it.
637 + if ( false === $start || false === $gt || false === $end || $gt > $pos ) {
638 + $offset = $pos + strlen( $needle );
639 + continue;
640 + }
641 +
642 + $html = substr( $html, 0, $start ) . substr( $html, $end + 9 );
643 + $offset = $start;
644 + }
645 +
646 + return $html;
647 + }
648 +
649 + /**
410 650 * Convert one Web Font Loader families[] entry into a structured spec:
411 651 * { family: 'Open Sans', weights: ['400','700'], italics: ['400'], subset: '' }
412 652 *
413 653 * Handles plain names, weight lists, FVD notation ("n4"/"i7"), a trailing
@@ -675,10 +915,9 @@
675 915 * --------------------------------------------------------------------- */
676 916
677 917 /**
678 918 * Register every variant and write one consolidated stylesheet. The file is
679 - * keyed by the variant set + display + subsets, so pages sharing fonts share
680 - * the file.
919 + * named by its content, so pages sharing fonts share the file.
681 920 *
682 921 * Registration runs for EVERY variant on every request (idempotent upsert),
683 922 * even when the consolidated file already exists — so the hosted-fonts list
684 923 * can't drift out of sync with what's actually served. Font binaries are
@@ -688,71 +927,127 @@
688 927 * @param array<string,array<string,mixed>> $variants Variants by key.
689 928 * @return string|null Local stylesheet URL (cache-busted), or null.
690 929 */
691 930 private function host( array $variants ): ?string {
692 - $keys = array_keys( $variants );
693 - sort( $keys );
931 + $display = (string) Settings::get( 'font_display', 'swap' );
932 + $subsets = implode( ',', (array) Settings::get( 'subsets', array() ) );
694 933
695 - $display = (string) Settings::get( 'font_display', 'swap' );
696 - $subsets = implode( ',', (array) Settings::get( 'subsets', array() ) );
934 + $disabled = $this->registry->disabled_keys();
697 935
698 - $disabled = $this->registry->disabled_keys();
936 + // Page scoping: when the route has been measured, drop only the fonts a
937 + // beacon CONFIRMED are loaded-but-unused here (e.g. a theme enqueues a
938 + // global Google-fonts set, but a font only renders on one template).
939 + // Matched at the family level and beacon-confirmed, so a newly declared
940 + // font is never dropped before it's been judged, and the decision can't
941 + // oscillate (it's based on the page's authorial font-family, which holds
942 + // even after the font is scoped out).
943 + // - family is a confirmed scope-out -> skip on this page
944 + // - family in the safelist -> always include
945 + // - everything else (used, or new/unjudged) -> include
946 + $scope = $this->route_measured
947 + && (bool) apply_filters( 'easyfonts_page_scope_fonts', true );
699 948
700 - // Fold the disabled set into the cache key so enabling/disabling a
701 - // variant yields a different stylesheet filename (no stale CSS served).
702 - $disabled_sig = implode( ',', array_keys( array_intersect_key( array_flip( $keys ), $disabled ) ) );
703 - $hash = substr( hash( 'sha256', implode( '|', $keys ) . '|' . $display . '|' . $subsets . '|x:' . $disabled_sig ), 0, 20 );
949 + $force = array();
704 950
705 - // Consolidated stylesheet lives in a readable `css/` folder (no doubled
706 - // hash folder, which security scanners flag). Font binaries live under
707 - // `fonts/{family}/…` so identical variants are shared across stylesheets.
708 - $file = 'css/' . $hash . '.css';
709 - $file_exists = $this->storage->exists( $file );
951 + foreach ( (array) apply_filters( 'easyfonts_force_families', array() ) as $f ) {
952 + $force[ strtolower( (string) $f ) ] = true;
953 + }
710 954
955 + // Decide, per detected variant, whether it is EMITTED on this page.
956 + // Skipped variants are still registered below so they stay visible and
957 + // re-enableable, and are still hosted by the routes that use them.
958 + $emit = array();
959 +
960 + foreach ( $variants as $key => $v ) {
961 + if ( isset( $disabled[ $key ] ) ) {
962 + continue;
963 + }
964 +
965 + if ( $scope ) {
966 + $fam = strtolower( (string) $v['family'] );
967 +
968 + if ( isset( $this->scope_out[ $fam ] ) && ! isset( $force[ $fam ] ) ) {
969 + continue;
970 + }
971 + }
972 +
973 + $emit[ $key ] = true;
974 + }
975 +
976 + // The emitted set (+ display + subsets), recorded on each variant row as
977 + // its css_file. The stylesheet itself is named by its CONTENT (below).
978 + $emit_keys = array_keys( $emit );
979 + sort( $emit_keys );
980 + $hash = substr( hash( 'sha256', implode( '|', $emit_keys ) . '|' . $display . '|' . $subsets ), 0, 20 );
981 + $file = 'css/' . $hash . '.css';
982 +
983 + // M-3: one batched read of the rows we already have for every detected
984 + // variant, so registration costs a single SELECT instead of one per
985 + // variant. We then only write rows that are new or whose font file
986 + // changed — on a warm-cache render that means zero writes.
987 + $known = $this->registry->existing_map( array_keys( $variants ) );
988 +
711 989 $css = "/**\n * Auto Generated by EasyFonts\n * @url: https://fluxpress.io\n */\n";
712 990 $hosted = 0;
713 991
714 992 foreach ( $variants as $key => $v ) {
715 - // User-disabled variant: keep it registered (so it shows in the
716 - // "unused/disabled" list to re-enable) but don't download, don't add
717 - // to the stylesheet, and don't preload.
718 - $is_disabled = isset( $disabled[ $key ] );
993 + $is_emitted = isset( $emit[ $key ] );
719 994
720 995 $basename = $this->basename_for( $v );
721 996 $font_file = $this->locate_font( $basename );
722 997 $size = $font_file ? $this->storage->size( $font_file ) : 0;
723 998
724 - if ( ! $is_disabled && null === $font_file ) {
999 + // Only EMITTED variants are required to be hosted on this page. A
1000 + // disabled or unloaded variant is never downloaded and never blocks
1001 + // the page (it isn't needed here); it may still be downloaded later
1002 + // by a route that actually renders it (font binaries are shared).
1003 + if ( $is_emitted && null === $font_file ) {
1004 + if ( ! Downloader::fonts_allowed() || ! $this->within_budget() ) {
1005 + $this->missing = true;
1006 + continue;
1007 + }
1008 +
725 1009 $download = $this->downloader->fetch_font( $v['src'], $basename, $this->storage );
726 1010
727 1011 if ( null === $download ) {
1012 + // Genuine fetch failure — treat as missing so we don't strip
1013 + // the original source and leave the page without this font.
1014 + $this->missing = true;
728 1015 continue;
729 1016 }
730 1017
1018 + $this->dl_count++;
731 1019 $font_file = $download['file'];
732 1020 $size = $download['size'];
733 1021 }
734 1022
735 - $this->registry->upsert(
736 - array(
737 - 'family' => $v['family'],
738 - 'weight' => $v['weight'],
739 - 'style' => $v['style'],
740 - 'subset' => $v['subset'],
741 - 'is_variable' => $v['is_variable'],
742 - 'provider' => $v['provider'],
743 - 'css_file' => $file,
744 - 'font_file' => (string) $font_file,
745 - 'file_size' => $size,
746 - 'source_url' => $v['source'],
747 - )
748 - );
1023 + // Register the variant, but skip the write when we already have it
1024 + // with the same font file (the common warm-cache case) — keeps the
1025 + // hosted-fonts list in sync without N queries on the hot path.
1026 + $current = $known[ $key ] ?? null;
749 1027
750 - if ( $is_disabled || null === $font_file ) {
1028 + if ( null === $current || (string) $current !== (string) $font_file ) {
1029 + $this->registry->upsert(
1030 + array(
1031 + 'family' => $v['family'],
1032 + 'weight' => $v['weight'],
1033 + 'style' => $v['style'],
1034 + 'subset' => $v['subset'],
1035 + 'is_variable' => $v['is_variable'],
1036 + 'provider' => $v['provider'],
1037 + 'css_file' => $file,
1038 + 'font_file' => (string) $font_file,
1039 + 'file_size' => $size,
1040 + 'source_url' => $v['source'],
1041 + )
1042 + );
1043 + }
1044 +
1045 + if ( ! $is_emitted || null === $font_file ) {
751 1046 continue;
752 1047 }
753 1048
754 - $local_url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $font_file ) );
1049 + $local_url = $this->storage->font_url( $font_file );
755 1050 $css .= $this->build_face_rule( $v, $local_url, $display );
756 1051
757 1052 self::$touched[] = array(
758 1053 'family' => $v['family'],
@@ -766,19 +1061,25 @@
766 1061 if ( 0 === $hosted ) {
767 1062 return null;
768 1063 }
769 1064
770 - // Rewrite the stylesheet whenever the enabled set might have changed
771 - // (cheap; keyed file means content is deterministic for a given set).
772 - if ( ! $file_exists ) {
773 - if ( ! $this->storage->write( $file, $css ) ) {
1065 + // (2.0.4) Named by CONTENT. Keyed by the emitted set alone, a file written
1066 + // once outlived any later change to what it should contain (a font
1067 + // re-downloaded after Purge, a CDN switch) and kept serving stale URLs,
1068 + // as did the inlined copy cached under its name. Identical content still
1069 + // shares one file and is never rewritten; older files are left for
1070 + // page-cached HTML that may still link them (Purge removes them).
1071 + $sheet = 'css/' . substr( hash( 'sha256', $css ), 0, 20 ) . '.css';
1072 +
1073 + if ( ! $this->storage->exists( $sheet ) ) {
1074 + if ( ! $this->storage->write( $sheet, $css ) ) {
774 1075 return null;
775 1076 }
776 1077 }
777 1078
778 - self::$stylesheet_file = $file;
1079 + self::$stylesheet_file = $sheet;
779 1080
780 - return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $file ) );
1081 + return add_query_arg( 'ver', Settings::buster(), $this->storage->url( $sheet ) );
781 1082 }
782 1083
783 1084 /**
784 1085 * Find an already-downloaded font file for a basename (any known extension).
@@ -809,22 +1110,34 @@
809 1110 private function build_face_rule( array $v, string $local_url, string $display ): string {
810 1111 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $local_url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
811 1112 $format = 'woff' === $ext ? 'woff' : ( 'ttf' === $ext ? 'truetype' : ( 'otf' === $ext ? 'opentype' : 'woff2' ) );
812 1113
1114 + // Harden every interpolated value against CSS-context breakout. Family /
1115 + // weight / style / unicode-range originate from parsed @font-face bodies
1116 + // and font URLs, which an author could craft; stripping CSS structural
1117 + // characters stops a malicious value from escaping its declaration (and,
1118 + // when the sheet is inlined, from breaking out of <style>). Legitimate
1119 + // values — "Open Sans", "100 900", "oblique 10deg", "U+0000-00FF" — are
1120 + // unaffected, so nothing is lost.
1121 + $family = self::css_token( (string) $v['family'] );
1122 + $style = self::css_token( (string) $v['style'] );
1123 + $weight = self::css_token( (string) $v['weight'] );
1124 + $urange = self::css_token( (string) $v['unicode_range'] );
1125 +
813 1126 $lines = array();
814 1127 $lines[] = '@font-face {';
815 - $lines[] = " font-family: '" . str_replace( "'", '', $v['family'] ) . "';";
816 - $lines[] = ' font-style: ' . $v['style'] . ';';
817 - $lines[] = ' font-weight: ' . $v['weight'] . ';';
1128 + $lines[] = " font-family: '" . $family . "';";
1129 + $lines[] = ' font-style: ' . ( '' !== $style ? $style : 'normal' ) . ';';
1130 + $lines[] = ' font-weight: ' . ( '' !== $weight ? $weight : '400' ) . ';';
818 1131
819 1132 if ( '' !== $display && 'auto' !== $display ) {
820 - $lines[] = ' font-display: ' . $display . ';';
1133 + $lines[] = ' font-display: ' . self::css_token( $display ) . ';';
821 1134 }
822 1135
823 1136 $lines[] = " src: url('" . esc_url_raw( $local_url ) . "') format('" . $format . "');";
824 1137
825 - if ( '' !== $v['unicode_range'] ) {
826 - $lines[] = ' unicode-range: ' . $v['unicode_range'] . ';';
1138 + if ( '' !== $urange ) {
1139 + $lines[] = ' unicode-range: ' . $urange . ';';
827 1140 }
828 1141
829 1142 $lines[] = "}\n";
830 1143
@@ -831,8 +1144,28 @@
831 1144 return implode( "\n", $lines );
832 1145 }
833 1146
834 1147 /**
1148 + * Strip characters that could break out of a CSS string or declaration (or,
1149 + * for an inlined sheet, out of the <style> element). Removes quotes, braces,
1150 + * semicolons, angle brackets, backslashes, comment delimiters and newlines.
1151 + * Real font-family / weight / style / unicode-range values never contain
1152 + * these, so this is purely defensive and lossless for legitimate input.
1153 + *
1154 + * @param string $value Raw value.
1155 + * @return string
1156 + */
1157 + private static function css_token( string $value ): string {
1158 + return trim(
1159 + str_replace(
1160 + array( "'", '"', ';', '{', '}', '<', '>', '\\', '/*', '*/', "\r", "\n", "\t" ),
1161 + '',
1162 + $value
1163 + )
1164 + );
1165 + }
1166 +
1167 + /**
835 1168 * Deterministic, human-readable font path for a variant, grouped by family.
836 1169 * Variable fonts drop the weight. Returns a cache-relative basename (no ext).
837 1170 *
838 1171 * e.g. fonts/open-sans/open-sans-normal-latin-400
@@ -884,9 +1217,9 @@
884 1217 $effective = $this->effective_subsets();
885 1218 $out = $this->absolutize_urls( $css, $base_url );
886 1219
887 1220 // 1. Provider @import → fetch that CSS, localise its faces, inline it.
888 - $out = (string) preg_replace_callback(
1221 + $imported_out = preg_replace_callback(
889 1222 '/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s]+)[\'"]?\s*\)?\s*;?/i',
890 1223 function ( $m ) use ( &$found, $effective ) {
891 1224 $url = Providers::normalize_url( $m[1] );
892 1225
@@ -905,8 +1238,9 @@
905 1238 return $this->localize_faces_in_css( $imported, $url );
906 1239 },
907 1240 $out
908 1241 );
1242 + $out = is_string( $imported_out ) ? $imported_out : $out;
909 1243
910 1244 // 2. Provider @font-face already in the file → localise src in place.
911 1245 $out = $this->localize_faces_in_css( $out, $base_url, $found );
912 1246
@@ -993,19 +1327,29 @@
993 1327 $font_file = $this->locate_font( $basename );
994 1328 $size = $font_file ? $this->storage->size( $font_file ) : 0;
995 1329
996 1330 if ( null === $font_file ) {
1331 + // As in host(): never download on a visitor render, and honour the
1332 + // warm budget. Mark missing and leave the original @font-face src
1333 + // in place so the font keeps loading from the provider until warm.
1334 + if ( ! Downloader::fonts_allowed() || ! $this->within_budget() ) {
1335 + $this->missing = true;
1336 + continue;
1337 + }
1338 +
997 1339 $dl = $this->downloader->fetch_font( $src, $basename, $this->storage );
998 1340
999 1341 if ( null === $dl ) {
1342 + $this->missing = true;
1000 1343 continue; // Leave the original src if the download fails.
1001 1344 }
1002 1345
1346 + $this->dl_count++;
1003 1347 $font_file = $dl['file'];
1004 1348 $size = $dl['size'];
1005 1349 }
1006 1350
1007 - $local_url = add_query_arg( 'ver', Settings::buster(), $this->storage->url( $font_file ) );
1351 + $local_url = $this->storage->font_url( $font_file );
1008 1352
1009 1353 $this->register_variant( $variant, $font_file, $size );
1010 1354
1011 1355 $css = $this->str_replace_once( $block['full'], rtrim( $this->build_face_rule( $variant, $local_url, $display ) ), $css );
@@ -1122,9 +1466,9 @@
1122 1466 * @param string $base_url Base URL of the original file.
1123 1467 * @return string
1124 1468 */
1125 1469 private function absolutize_urls( string $css, string $base_url ): string {
1126 - return (string) preg_replace_callback(
1470 + $out = preg_replace_callback(
1127 1471 '/url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i',
1128 1472 function ( $m ) use ( $base_url ) {
1129 1473 $url = trim( $m[2] );
1130 1474
@@ -1139,8 +1483,10 @@
1139 1483 return "url('" . Providers::normalize_url( $url, $base_url ) . "')";
1140 1484 },
1141 1485 $css
1142 1486 );
1487 +
1488 + return is_string( $out ) ? $out : $css;
1143 1489 }
1144 1490
1145 1491 /**
1146 1492 * Normalize a subset comment token (e.g. "latin", "[0]" → '').