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
easyfonts / src / Fonts / Storage.php

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

275 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Font cache storage.
4 *
5 * All writes are atomic (tmp + rename) so a concurrent request never serves a
6 * half-written CSS or font file.
7 *
8 * @package EasyFonts
9 */
10
11 namespace EasyFonts\Fonts;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Reads/writes files in the cache directory.
17 */
18 class Storage {
19
20 /**
21 * Ensure the cache directory exists (and is shielded from listing).
22 *
23 * @return bool
24 */
25 public function ensure_dir(): bool {
26 if ( is_dir( EASYFONTS_CACHE_DIR ) ) {
27 return true;
28 }
29
30 $made = wp_mkdir_p( EASYFONTS_CACHE_DIR );
31
32 if ( $made ) {
33 $index = trailingslashit( EASYFONTS_CACHE_DIR ) . 'index.html';
34
35 if ( ! file_exists( $index ) ) {
36 @file_put_contents( $index, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
37 }
38 }
39
40 return $made;
41 }
42
43 /**
44 * Absolute path for a cache-relative filename.
45 *
46 * Defence-in-depth: `..` segments are stripped so a filename can never
47 * escape the cache directory, even though filenames are internally
48 * generated (hashes / slugs) and not user-supplied.
49 *
50 * @param string $filename Filename.
51 * @return string
52 */
53 public function path( string $filename ): string {
54 $filename = str_replace( "\0", '', ltrim( $filename, '/' ) );
55
56 // Iteratively strip '..' so a removal can never re-form a traversal
57 // sequence (a single-pass '../' replace turns '....//' into '../').
58 while ( false !== strpos( $filename, '..' ) ) {
59 $filename = str_replace( '..', '', $filename );
60 }
61
62 return trailingslashit( EASYFONTS_CACHE_DIR ) . $filename;
63 }
64
65 /**
66 * Public URL for a cache-relative filename.
67 *
68 * When a CDN base is configured (Settings → cdn_url), the site origin is
69 * swapped for the CDN origin while the path is preserved — the standard
70 * pull-zone model. This one method is the single emission point for the
71 * consolidated stylesheet, every @font-face src, localised external CSS,
72 * and all preloads, so the CDN applies everywhere consistently.
73 *
74 * @param string $filename Filename.
75 * @return string
76 */
77 public function url( string $filename ): string {
78 $url = trailingslashit( EASYFONTS_CACHE_URL ) . ltrim( $filename, '/' );
79
80 return self::cdn_rewrite( $url );
81 }
82
83 /**
84 * Public URL of a hosted FONT, versioned by the file itself.
85 *
86 * (2.0.4) Font URLs used to carry the site-wide cache-buster, which every
87 * settings save bumps. The consolidated stylesheet is written once and
88 * reused, so its @font-face URLs kept the old buster while preloads took
89 * the new one: two URLs for one file — the preload went unused ("preloaded
90 * but not used") and the font downloaded twice. A file's own modification
91 * time changes only when the file itself is (re)written, so every place
92 * that links a font — stylesheet, preload, localised theme CSS — computes
93 * the same URL, and a settings save no longer makes every returning visitor
94 * download every font again. Fonts are served immutable for a year, so the
95 * version is still needed for when a file really is re-downloaded.
96 *
97 * @param string $filename Cache-relative font filename.
98 * @return string
99 */
100 public function font_url( string $filename ): string {
101 $mtime = @filemtime( $this->path( $filename ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
102
103 return add_query_arg( 'ver', $mtime ? (int) $mtime : \EasyFonts\Settings::buster(), $this->url( $filename ) );
104 }
105
106 /**
107 * Rewrite a same-origin asset URL onto the configured CDN origin.
108 *
109 * The CDN value is treated as an origin (scheme + host[/optional base]);
110 * the asset path is preserved. Returns the URL unchanged when no CDN is set,
111 * the CDN is invalid, or the URL isn't same-origin.
112 *
113 * @param string $url Absolute same-origin URL.
114 * @return string
115 */
116 public static function cdn_rewrite( string $url ): string {
117 $cdn = self::cdn_base();
118
119 if ( '' === $cdn || '' === $url ) {
120 return $url;
121 }
122
123 $home = wp_parse_url( home_url() );
124
125 if ( empty( $home['host'] ) ) {
126 return $url;
127 }
128
129 $origin = ( isset( $home['scheme'] ) ? $home['scheme'] : 'https' ) . '://' . $home['host']
130 . ( isset( $home['port'] ) ? ':' . $home['port'] : '' );
131
132 // Only rewrite our own origin; never touch third-party URLs.
133 if ( 0 === strpos( $url, $origin ) ) {
134 return $cdn . substr( $url, strlen( $origin ) );
135 }
136
137 // Protocol-relative or root-relative cache URLs (rare) → prefix the CDN.
138 if ( 0 === strpos( $url, '//' ) ) {
139 return $cdn . substr( $url, strpos( $url, '/', 2 ) );
140 }
141
142 return $url;
143 }
144
145 /**
146 * Normalised CDN origin from settings ('' when unset/invalid). Cached.
147 *
148 * @return string
149 */
150 private static function cdn_base(): string {
151 static $base = null;
152
153 if ( null !== $base ) {
154 return $base;
155 }
156
157 $raw = trim( (string) \EasyFonts\Settings::get( 'cdn_url', '' ) );
158
159 if ( '' === $raw ) {
160 return $base = '';
161 }
162
163 // Accept protocol-relative input by adopting the current scheme.
164 if ( 0 === strpos( $raw, '//' ) ) {
165 $raw = ( is_ssl() ? 'https:' : 'http:' ) . $raw;
166 }
167
168 if ( ! preg_match( '#^https?://#i', $raw ) ) {
169 return $base = '';
170 }
171
172 return $base = untrailingslashit( esc_url_raw( $raw ) );
173 }
174
175 /**
176 * Does a cache file exist?
177 *
178 * @param string $filename Filename.
179 * @return bool
180 */
181 public function exists( string $filename ): bool {
182 return is_file( $this->path( $filename ) );
183 }
184
185 /**
186 * File size in bytes (0 if missing).
187 *
188 * @param string $filename Filename.
189 * @return int
190 */
191 public function size( string $filename ): int {
192 $path = $this->path( $filename );
193
194 return is_file( $path ) ? (int) filesize( $path ) : 0;
195 }
196
197 /**
198 * Atomically write content to a cache file.
199 *
200 * @param string $filename Filename (may include a subdir).
201 * @param string $content Bytes.
202 * @return bool
203 */
204 public function write( string $filename, string $content ): bool {
205 $this->ensure_dir();
206
207 $path = $this->path( $filename );
208 $dir = dirname( $path );
209
210 if ( ! is_dir( $dir ) ) {
211 wp_mkdir_p( $dir );
212 }
213
214 $tmp = $path . '.' . wp_generate_password( 6, false ) . '.tmp';
215
216 // phpcs:disable WordPress.WP.AlternativeFunctions
217 $written = file_put_contents( $tmp, $content, LOCK_EX );
218
219 if ( false === $written || $written < strlen( $content ) ) {
220 @unlink( $tmp );
221 return false;
222 }
223
224 if ( ! @rename( $tmp, $path ) ) {
225 @unlink( $tmp );
226 return false;
227 }
228 // phpcs:enable WordPress.WP.AlternativeFunctions
229
230 return true;
231 }
232
233 /**
234 * Total cache size in bytes.
235 *
236 * @return int
237 */
238 public function total_size(): int {
239 if ( ! is_dir( EASYFONTS_CACHE_DIR ) ) {
240 return 0;
241 }
242
243 $bytes = 0;
244 $iter = new \RecursiveIteratorIterator(
245 new \RecursiveDirectoryIterator( EASYFONTS_CACHE_DIR, \FilesystemIterator::SKIP_DOTS )
246 );
247
248 foreach ( $iter as $file ) {
249 $bytes += $file->getSize();
250 }
251
252 return $bytes;
253 }
254
255 /**
256 * Remove every cached file.
257 */
258 public function purge(): void {
259 if ( ! is_dir( EASYFONTS_CACHE_DIR ) ) {
260 return;
261 }
262
263 $iter = new \RecursiveIteratorIterator(
264 new \RecursiveDirectoryIterator( EASYFONTS_CACHE_DIR, \FilesystemIterator::SKIP_DOTS ),
265 \RecursiveIteratorIterator::CHILD_FIRST
266 );
267
268 // phpcs:disable WordPress.WP.AlternativeFunctions
269 foreach ( $iter as $file ) {
270 $file->isDir() ? @rmdir( $file->getRealPath() ) : @unlink( $file->getRealPath() );
271 }
272 // phpcs:enable WordPress.WP.AlternativeFunctions
273 }
274 }
275