PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.3
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.3
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.3, at src/Fonts/Storage.php

252 lines 6.0 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 * Rewrite a same-origin asset URL onto the configured CDN origin.
85 *
86 * The CDN value is treated as an origin (scheme + host[/optional base]);
87 * the asset path is preserved. Returns the URL unchanged when no CDN is set,
88 * the CDN is invalid, or the URL isn't same-origin.
89 *
90 * @param string $url Absolute same-origin URL.
91 * @return string
92 */
93 public static function cdn_rewrite( string $url ): string {
94 $cdn = self::cdn_base();
95
96 if ( '' === $cdn || '' === $url ) {
97 return $url;
98 }
99
100 $home = wp_parse_url( home_url() );
101
102 if ( empty( $home['host'] ) ) {
103 return $url;
104 }
105
106 $origin = ( isset( $home['scheme'] ) ? $home['scheme'] : 'https' ) . '://' . $home['host']
107 . ( isset( $home['port'] ) ? ':' . $home['port'] : '' );
108
109 // Only rewrite our own origin; never touch third-party URLs.
110 if ( 0 === strpos( $url, $origin ) ) {
111 return $cdn . substr( $url, strlen( $origin ) );
112 }
113
114 // Protocol-relative or root-relative cache URLs (rare) → prefix the CDN.
115 if ( 0 === strpos( $url, '//' ) ) {
116 return $cdn . substr( $url, strpos( $url, '/', 2 ) );
117 }
118
119 return $url;
120 }
121
122 /**
123 * Normalised CDN origin from settings ('' when unset/invalid). Cached.
124 *
125 * @return string
126 */
127 private static function cdn_base(): string {
128 static $base = null;
129
130 if ( null !== $base ) {
131 return $base;
132 }
133
134 $raw = trim( (string) \EasyFonts\Settings::get( 'cdn_url', '' ) );
135
136 if ( '' === $raw ) {
137 return $base = '';
138 }
139
140 // Accept protocol-relative input by adopting the current scheme.
141 if ( 0 === strpos( $raw, '//' ) ) {
142 $raw = ( is_ssl() ? 'https:' : 'http:' ) . $raw;
143 }
144
145 if ( ! preg_match( '#^https?://#i', $raw ) ) {
146 return $base = '';
147 }
148
149 return $base = untrailingslashit( esc_url_raw( $raw ) );
150 }
151
152 /**
153 * Does a cache file exist?
154 *
155 * @param string $filename Filename.
156 * @return bool
157 */
158 public function exists( string $filename ): bool {
159 return is_file( $this->path( $filename ) );
160 }
161
162 /**
163 * File size in bytes (0 if missing).
164 *
165 * @param string $filename Filename.
166 * @return int
167 */
168 public function size( string $filename ): int {
169 $path = $this->path( $filename );
170
171 return is_file( $path ) ? (int) filesize( $path ) : 0;
172 }
173
174 /**
175 * Atomically write content to a cache file.
176 *
177 * @param string $filename Filename (may include a subdir).
178 * @param string $content Bytes.
179 * @return bool
180 */
181 public function write( string $filename, string $content ): bool {
182 $this->ensure_dir();
183
184 $path = $this->path( $filename );
185 $dir = dirname( $path );
186
187 if ( ! is_dir( $dir ) ) {
188 wp_mkdir_p( $dir );
189 }
190
191 $tmp = $path . '.' . wp_generate_password( 6, false ) . '.tmp';
192
193 // phpcs:disable WordPress.WP.AlternativeFunctions
194 $written = file_put_contents( $tmp, $content, LOCK_EX );
195
196 if ( false === $written || $written < strlen( $content ) ) {
197 @unlink( $tmp );
198 return false;
199 }
200
201 if ( ! @rename( $tmp, $path ) ) {
202 @unlink( $tmp );
203 return false;
204 }
205 // phpcs:enable WordPress.WP.AlternativeFunctions
206
207 return true;
208 }
209
210 /**
211 * Total cache size in bytes.
212 *
213 * @return int
214 */
215 public function total_size(): int {
216 if ( ! is_dir( EASYFONTS_CACHE_DIR ) ) {
217 return 0;
218 }
219
220 $bytes = 0;
221 $iter = new \RecursiveIteratorIterator(
222 new \RecursiveDirectoryIterator( EASYFONTS_CACHE_DIR, \FilesystemIterator::SKIP_DOTS )
223 );
224
225 foreach ( $iter as $file ) {
226 $bytes += $file->getSize();
227 }
228
229 return $bytes;
230 }
231
232 /**
233 * Remove every cached file.
234 */
235 public function purge(): void {
236 if ( ! is_dir( EASYFONTS_CACHE_DIR ) ) {
237 return;
238 }
239
240 $iter = new \RecursiveIteratorIterator(
241 new \RecursiveDirectoryIterator( EASYFONTS_CACHE_DIR, \FilesystemIterator::SKIP_DOTS ),
242 \RecursiveIteratorIterator::CHILD_FIRST
243 );
244
245 // phpcs:disable WordPress.WP.AlternativeFunctions
246 foreach ( $iter as $file ) {
247 $file->isDir() ? @rmdir( $file->getRealPath() ) : @unlink( $file->getRealPath() );
248 }
249 // phpcs:enable WordPress.WP.AlternativeFunctions
250 }
251 }
252