PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.1
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.1
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
easyfonts / src / Fonts / Downloader.php

Downloader.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.1, at src/Fonts/Downloader.php

310 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Remote fetcher for stylesheets and font binaries.
4 *
5 * @package EasyFonts
6 */
7
8 namespace EasyFonts\Fonts;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Downloads Google/Bunny CSS and font files.
14 */
15 class Downloader {
16
17 /**
18 * Whether remote font-binary downloads are allowed in the current request.
19 * Set to false during a normal visitor render (so the page is never blocked
20 * by synchronous downloads); true during admin/CLI/warm (probe) requests.
21 *
22 * @var bool
23 */
24 private static bool $allow_fonts = true;
25
26 /**
27 * Allow/disallow remote font-binary downloads for this request.
28 *
29 * @param bool $allow Allowed?
30 * @return void
31 */
32 public static function set_allow_fonts( bool $allow ): void {
33 self::$allow_fonts = $allow;
34 }
35
36 /**
37 * Are remote font-binary downloads currently allowed?
38 *
39 * @return bool
40 */
41 public static function fonts_allowed(): bool {
42 return self::$allow_fonts;
43 }
44
45 /**
46 * Modern desktop UA so the API returns woff2.
47 */
48 const UA_WOFF2 = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0';
49
50 /**
51 * Old UA that makes the API return TTF (used for metric extraction only).
52 */
53 const UA_TTF = 'Mozilla/5.0 (Windows NT 6.1; rv:27.0) Gecko/20100101 Firefox/27.0';
54
55 /**
56 * Content-Type => extension.
57 */
58 const MIME = array(
59 'font/woff2' => 'woff2',
60 'application/font-woff2' => 'woff2',
61 'font/woff' => 'woff',
62 'application/font-woff' => 'woff',
63 'font/ttf' => 'ttf',
64 'application/x-font-ttf' => 'ttf',
65 'font/sfnt' => 'ttf',
66 'application/font-sfnt' => 'ttf',
67 'font/otf' => 'otf',
68 'application/x-font-opentype' => 'otf',
69 'application/vnd.ms-fontobject' => 'eot',
70 );
71
72 /**
73 * Fetch CSS text from a font-provider URL.
74 *
75 * @param string $url Stylesheet URL.
76 * @return string|null
77 */
78 public function fetch_css( string $url ): ?string {
79 $url = \EasyFonts\Detect\Providers::normalize_url( $url );
80
81 // Cache the fetched CSS so font detection doesn't hit the network on every
82 // page render. Keyed by URL + cache-buster, so any settings/font change
83 // (which bumps the buster) transparently refreshes it.
84 $cache_key = 'easyfonts_css_' . md5( $url . '|' . \EasyFonts\Settings::buster() );
85 $cached = get_transient( $cache_key );
86
87 if ( is_string( $cached ) && '' !== $cached ) {
88 return $cached;
89 }
90
91 $response = wp_remote_get(
92 $url,
93 array(
94 'timeout' => 10,
95 'user-agent' => apply_filters( 'easyfonts_css_user_agent', self::UA_WOFF2 ),
96 )
97 );
98
99 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
100 return null;
101 }
102
103 $body = wp_remote_retrieve_body( $response );
104
105 if ( '' === $body ) {
106 return null;
107 }
108
109 set_transient( $cache_key, $body, 7 * DAY_IN_SECONDS );
110
111 return $body;
112 }
113
114 /**
115 * Download a font binary into the cache and return [filename, ext, size].
116 *
117 * @param string $url Font file URL.
118 * @param string $basename Cache basename (without extension).
119 * @param Storage $storage Storage.
120 * @return array{file:string,ext:string,size:int}|null
121 */
122 public function fetch_font( string $url, string $basename, Storage $storage ): ?array {
123 // Never download a font binary during a normal visitor page render — that
124 // is what could stall the request and white-screen the site. On the render
125 // path we serve only already-cached fonts; missing ones are fetched by a
126 // background warm (probe) request. Admin/CLI/warm requests set this true.
127 if ( ! self::$allow_fonts ) {
128 return null;
129 }
130
131 $url = \EasyFonts\Detect\Providers::normalize_url( $url );
132
133 // SSRF guard: only fetch font binaries from recognised provider hosts
134 // (or the site's own origin). A crafted @font-face can otherwise point
135 // src at an internal address; this blocks that without affecting any
136 // legitimate Google/Bunny/wp.com font. Filterable for niche providers.
137 if ( ! self::host_allowed( $url ) ) {
138 return null;
139 }
140
141 $response = wp_remote_get(
142 $url,
143 array(
144 'timeout' => 10,
145 'user-agent' => self::UA_WOFF2,
146 )
147 );
148
149 if ( is_wp_error( $response ) ) {
150 return null;
151 }
152
153 $code = (int) wp_remote_retrieve_response_code( $response );
154
155 if ( $code < 200 || $code >= 300 ) {
156 return null;
157 }
158
159 $body = wp_remote_retrieve_body( $response );
160
161 if ( '' === $body ) {
162 return null;
163 }
164
165 $ct = strtolower( trim( explode( ';', (string) wp_remote_retrieve_header( $response, 'content-type' ) )[0] ) );
166 $ext = self::MIME[ $ct ] ?? $this->ext_from_url( $url );
167
168 $filename = $basename . '.' . $ext;
169
170 if ( ! $storage->write( $filename, $body ) ) {
171 return null;
172 }
173
174 return array(
175 'file' => $filename,
176 'ext' => $ext,
177 'size' => strlen( $body ),
178 );
179 }
180
181 /**
182 * Fetch the raw bytes of a TTF copy of a Google Fonts family for metrics.
183 * Returns the binary, never writes it to disk.
184 *
185 * @param string $css_url Original CSS URL.
186 * @return string|null
187 */
188 public function fetch_ttf_bytes( string $css_url ): ?string {
189 $response = wp_remote_get(
190 $css_url,
191 array(
192 'timeout' => 20,
193 'user-agent' => self::UA_TTF,
194 )
195 );
196
197 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
198 return null;
199 }
200
201 $css = wp_remote_retrieve_body( $response );
202
203 if ( ! preg_match( '/src\s*:\s*[^;]*?url\(\s*[\'"]?([^\'")]+\.(?:ttf|otf))[\'"]?\s*\)/i', $css, $m ) ) {
204 return null;
205 }
206
207 $font = wp_remote_get( $m[1], array( 'timeout' => 20 ) );
208
209 if ( is_wp_error( $font ) || 200 !== (int) wp_remote_retrieve_response_code( $font ) ) {
210 return null;
211 }
212
213 $bytes = wp_remote_retrieve_body( $font );
214
215 return '' === $bytes ? null : $bytes;
216 }
217
218 /**
219 * Fetch TTF bytes for a specific Google family/weight purely for metric
220 * extraction. Source-independent: we construct a css2 request from the
221 * family name, so it works whether the font came from a <link>, @import, or
222 * an inline @font-face. Returns binary, never written to disk.
223 *
224 * @param string $family Family name (e.g. "Open Sans").
225 * @param string $weight Representative numeric weight.
226 * @return string|null
227 */
228 public function fetch_family_ttf( string $family, string $weight = '400' ): ?string {
229 $weight = (string) absint( $weight );
230
231 if ( '' === $weight || '0' === $weight ) {
232 $weight = '400';
233 }
234
235 $url = 'https://fonts.googleapis.com/css2?family='
236 . rawurlencode( $family ) . ':wght@' . $weight . '&display=swap';
237 $url = str_replace( '%20', '+', $url );
238
239 return $this->fetch_ttf_bytes( $url );
240 }
241
242 /**
243 * Is this URL host allowed for font-binary downloads? Provider font hosts
244 * plus the site's own origin. Filterable via `easyfonts_font_src_hosts`.
245 *
246 * @param string $url Absolute URL.
247 * @return bool
248 */
249 private static function host_allowed( string $url ): bool {
250 $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
251
252 if ( '' === $host ) {
253 return false;
254 }
255
256 $own = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
257
258 /**
259 * Allowed host suffixes for downloading font binaries.
260 *
261 * @param string[] $hosts
262 */
263 $allowed = apply_filters(
264 'easyfonts_font_src_hosts',
265 array(
266 'fonts.gstatic.com',
267 'fonts.googleapis.com',
268 'fonts.bunny.net',
269 'fonts.wp.com',
270 'fonts-api.wp.com',
271 's.w.org',
272 'i0.wp.com',
273 'i1.wp.com',
274 'i2.wp.com',
275 )
276 );
277
278 if ( '' !== $own ) {
279 $allowed[] = $own;
280 }
281
282 foreach ( $allowed as $candidate ) {
283 $candidate = strtolower( trim( (string) $candidate ) );
284
285 if ( '' === $candidate ) {
286 continue;
287 }
288
289 // Exact host or a subdomain of an allowed host.
290 if ( $host === $candidate || substr( $host, -strlen( '.' . $candidate ) ) === '.' . $candidate ) {
291 return true;
292 }
293 }
294
295 return false;
296 }
297
298 /**
299 * Guess an extension from a URL path.
300 *
301 * @param string $url URL.
302 * @return string
303 */
304 private function ext_from_url( string $url ): string {
305 $ext = strtolower( (string) pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
306
307 return in_array( $ext, array( 'woff2', 'woff', 'ttf', 'otf', 'eot' ), true ) ? $ext : 'woff2';
308 }
309 }
310