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/Fonts/UsageTracker.php +213 -51 2.0.2 → 2.0.4 View file →
@@ -49,9 +49,15 @@
49 49 */
50 50 public static function route_for( string $url ): string {
51 51 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
52 52
53 - return '' === $path ? '/' : $path;
53 + if ( '' === $path ) {
54 + return '/';
55 + }
56 +
57 + // Guarantee a canonical leading slash — malformed input (e.g. a bare
58 + // scheme string) must never mint a route outside the path namespace.
59 + return '/' === $path[0] ? $path : '/' . $path;
54 60 }
55 61
56 62 /**
57 63 * Canonical route key for the current request.
@@ -75,9 +81,9 @@
75 81 if ( empty( $records ) ) {
76 82 return;
77 83 }
78 84
79 - $now = current_time( 'mysql' );
85 + $now = gmdate( 'Y-m-d H:i:s' ); // UTC — compared against UTC_TIMESTAMP() in gc().
80 86 $table = $this->usage_table();
81 87
82 88 // Collapse to one row per (page, family, weight, style); merge any
83 89 // duplicates within this batch (strongest rendered/above_fold wins,
@@ -129,9 +135,13 @@
129 135 $placeholders = array();
130 136 $args = array();
131 137
132 138 foreach ( $by_key as $row ) {
133 - $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%s)';
139 + // A beacon measurement that saw the variant loaded but not rendered
140 + // counts one "miss" toward the scope-out corroboration threshold.
141 + $miss = ( 'beacon' === $row['origin'] && 0 === $row['rendered'] ) ? 1 : 0;
142 +
143 + $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%d,%s)';
134 144 array_push(
135 145 $args,
136 146 $row['key'],
137 147 $page_url,
@@ -141,21 +151,29 @@
141 151 $row['style'],
142 152 $row['origin'],
143 153 $row['rendered'],
144 154 $row['above_fold'],
155 + $miss,
145 156 $now
146 157 );
147 158 }
148 159
160 + $alias = $this->dup_alias();
161 +
149 162 $sql = "INSERT INTO {$table}
150 - (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, last_seen)
151 - VALUES " . implode( ',', $placeholders ) . "
163 + (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, beacon_misses, last_seen)
164 + VALUES " . implode( ',', $placeholders ) . "{$alias}
152 165 ON DUPLICATE KEY UPDATE
153 166 hits = hits + 1,
154 - rendered = GREATEST(rendered, VALUES(rendered)),
155 - above_fold = GREATEST(above_fold, VALUES(above_fold)),
156 - origin = VALUES(origin),
157 - last_seen = VALUES(last_seen)";
167 + rendered = GREATEST(rendered, {$this->dup( 'rendered' )}),
168 + above_fold = GREATEST(above_fold, {$this->dup( 'above_fold' )}),
169 + origin = {$this->dup( 'origin' )},
170 + beacon_misses = CASE
171 + WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 1 THEN 0
172 + WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 0 THEN beacon_misses + 1
173 + ELSE beacon_misses
174 + END,
175 + last_seen = {$this->dup( 'last_seen' )}";
158 176
159 177 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
160 178 $wpdb->query( $wpdb->prepare( $sql, $args ) );
161 179 }
@@ -198,15 +216,20 @@
198 216 * are family-level and based on the page's authorial font-family, so they're
199 217 * robust to weight/style differences and self-correcting.
200 218 *
201 219 * - `rendered` drives page scoping (which fonts load on this page).
202 - * - `above_fold` drives preload (which of them to fetch early).
220 + * - `above_fold` the families above the fold.
221 + * - `fold_faces` drives preload: for every family rendered here, the faces
222 + * ('weight|style') the beacon saw above the fold — empty when the family
223 + * renders only further down. (2.0.4) Preload used to take the whole
224 + * family, so a family with many weights filled every slot with weights
225 + * the page never shows.
203 226 *
204 227 * Empty `rendered` means the route hasn't been measured yet — callers must
205 228 * neither scope nor preload from no data.
206 229 *
207 230 * @param string $route Canonical route (path).
208 - * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,scope_out:array<string,bool>,measured:bool}
231 + * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,fold_faces:array<string,array<string,bool>>,scope_out:array<string,bool>,measured:bool}
209 232 */
210 233 public function route_render_profile( string $route ): array {
211 234 global $wpdb;
212 235
@@ -212,8 +235,9 @@
212 235
213 236 $out = array(
214 237 'rendered' => array(),
215 238 'above_fold' => array(),
239 + 'fold_faces' => array(),
216 240 'scope_out' => array(),
217 241 'measured' => false,
218 242 );
219 243
@@ -228,43 +252,72 @@
228 252 // confirmed scope-out ONLY when a beacon has actually measured it and
229 253 // never saw it render. A family that hasn't been beacon-measured yet
230 254 // (e.g. a font just added/replaced in the Customizer) is NEVER scoped
231 255 // out — it loads normally until a beacon has had a chance to judge it.
256 + // Grouped per face (still one query); folded per family below.
232 257 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
233 258 $rows = $wpdb->get_results(
234 259 $wpdb->prepare(
235 - "SELECT family,
260 + "SELECT family, weight, style,
236 261 MAX(rendered) AS rnd,
237 262 MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af,
238 - MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed
263 + MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed,
264 + MAX(beacon_misses) AS misses
239 265 FROM {$this->usage_table()}
240 266 WHERE page_url = %s
241 - GROUP BY family",
267 + GROUP BY family, weight, style",
242 268 $route
243 269 ),
244 270 ARRAY_A
245 271 );
246 272
247 - if ( $rows ) {
248 - foreach ( $rows as $r ) {
249 - $fam = strtolower( (string) $r['family'] );
250 - $rendered = ! empty( $r['rnd'] );
251 - $beaconed = ! empty( $r['beaconed'] );
273 + $families = array();
252 274
253 - if ( $beaconed ) {
254 - $out['measured'] = true;
255 - }
275 + foreach ( (array) $rows as $r ) {
276 + $fam = strtolower( (string) $r['family'] );
277 + $agg = $families[ $fam ] ?? array( 'rnd' => 0, 'af' => 0, 'beaconed' => 0, 'misses' => 0, 'faces' => array() );
256 278
257 - if ( $rendered ) {
258 - $out['rendered'][ $fam ] = true;
279 + $agg['rnd'] = max( $agg['rnd'], (int) $r['rnd'] );
280 + $agg['af'] = max( $agg['af'], (int) $r['af'] );
281 + $agg['beaconed'] = max( $agg['beaconed'], (int) $r['beaconed'] );
282 + $agg['misses'] = max( $agg['misses'], (int) $r['misses'] );
259 283
260 - if ( ! empty( $r['af'] ) ) {
261 - $out['above_fold'][ $fam ] = true;
262 - }
263 - } elseif ( $beaconed ) {
264 - // Beacon measured it and never saw it render → scope out.
265 - $out['scope_out'][ $fam ] = true;
284 + if ( ! empty( $r['af'] ) ) {
285 + $agg['faces'][ $r['weight'] . '|' . $r['style'] ] = true;
286 + }
287 +
288 + $families[ $fam ] = $agg;
289 + }
290 +
291 + /**
292 + * Consistent beacon measurements required before a never-rendered
293 + * family is scoped off a page. Corroboration means one bad or forged
294 + * report can't remove a font (beacons are throttled hourly, so this is
295 + * also a minimum observation window).
296 + *
297 + * @param int $min
298 + */
299 + $min_misses = max( 1, (int) apply_filters( 'easyfonts_scope_out_min_measurements', 2 ) );
300 +
301 + foreach ( $families as $fam => $r ) {
302 + $rendered = ! empty( $r['rnd'] );
303 + $beaconed = ! empty( $r['beaconed'] );
304 +
305 + if ( $beaconed ) {
306 + $out['measured'] = true;
307 + }
308 +
309 + if ( $rendered ) {
310 + $out['rendered'][ $fam ] = true;
311 + $out['fold_faces'][ $fam ] = $r['faces'];
312 +
313 + if ( ! empty( $r['af'] ) ) {
314 + $out['above_fold'][ $fam ] = true;
266 315 }
316 + } elseif ( $beaconed && (int) $r['misses'] >= $min_misses ) {
317 + // Multiple beacons measured it and never saw it render →
318 + // confirmed scope-out.
319 + $out['scope_out'][ $fam ] = true;
267 320 }
268 321 }
269 322
270 323 return $out;
@@ -276,14 +329,27 @@
276 329 * wherever they appear without consulting per-route data (and even before a
277 330 * given route has been measured), which is the cheap path: no per-route
278 331 * check needed for the fonts whose answer is the same everywhere.
279 332 *
280 - * @return array<string,bool> Lowercased family => true.
333 + * (2.0.4) With the faces ('weight|style') those families render in. A value
334 + * of `true` is a family stored by 2.0.3 without its faces — it stands for
335 + * the whole family until the next recompute (next beacon, or weekly GC).
336 + *
337 + * @return array<string,array<string,bool>|true> Lowercased family => faces.
281 338 */
282 - public function global_preload_families(): array {
339 + public function global_preload_faces(): array {
283 340 $list = get_option( 'easyfonts_global_preload', array() );
341 + $out = array();
284 342
285 - return is_array( $list ) ? array_fill_keys( array_map( 'strval', $list ), true ) : array();
343 + foreach ( is_array( $list ) ? $list : array() as $key => $value ) {
344 + if ( is_array( $value ) ) {
345 + $out[ (string) $key ] = array_fill_keys( array_map( 'strval', $value ), true );
346 + } elseif ( is_int( $key ) ) {
347 + $out[ strtolower( (string) $value ) ] = true;
348 + }
349 + }
350 +
351 + return $out;
286 352 }
287 353
288 354 /**
289 355 * Recompute and cache the uniformly-above-the-fold family set. A family
@@ -288,10 +354,11 @@
288 354 /**
289 355 * Recompute and cache the uniformly-above-the-fold family set. A family
290 356 * qualifies when every measured row for it (across all routes/variants where
291 357 * it renders) is above the fold — so its preload answer doesn't vary by page
292 - * and we can skip per-route checks for it. Cheap: one grouped scan of the
293 - * (capped) usage table; called when a beacon is accepted and from GC.
358 + * and we can skip per-route checks for it. Stored with the faces it renders
359 + * in, so preload stays weight-level. Cheap: one grouped scan of the (capped)
360 + * usage table; called when a beacon is accepted and from GC.
294 361 *
295 362 * @return void
296 363 */
297 364 public function recompute_global_preload(): void {
@@ -297,18 +364,37 @@
297 364 public function recompute_global_preload(): void {
298 365 global $wpdb;
299 366
300 367 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
301 - $families = $wpdb->get_col(
302 - "SELECT family FROM {$this->usage_table()}
368 + $rows = $wpdb->get_results(
369 + "SELECT family, weight, style, MIN(above_fold) AS af FROM {$this->usage_table()}
303 370 WHERE rendered = 1
304 - GROUP BY family
305 - HAVING MIN(above_fold) = 1"
371 + GROUP BY family, weight, style",
372 + ARRAY_A
306 373 );
307 374
308 - $families = $families ? array_values( array_unique( array_map( 'strtolower', $families ) ) ) : array();
375 + $faces = array();
376 + $below = array();
309 377
310 - update_option( 'easyfonts_global_preload', $families, true );
378 + foreach ( (array) $rows as $r ) {
379 + $fam = strtolower( (string) $r['family'] );
380 +
381 + if ( empty( $r['af'] ) ) {
382 + $below[ $fam ] = true;
383 + }
384 +
385 + $faces[ $fam ][ $r['weight'] . '|' . $r['style'] ] = true;
386 + }
387 +
388 + $global = array();
389 +
390 + foreach ( $faces as $fam => $set ) {
391 + if ( ! isset( $below[ $fam ] ) ) {
392 + $global[ $fam ] = array_keys( $set );
393 + }
394 + }
395 +
396 + update_option( 'easyfonts_global_preload', $global, true );
311 397 }
312 398
313 399 /**
314 400 * Forget the stored decision for a route (all devices), so the next beacon
@@ -339,24 +425,26 @@
339 425 global $wpdb;
340 426
341 427 $key = sha1( $route . '|' . $device );
342 428
429 + $alias = $this->dup_alias();
430 +
343 431 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
344 432 $wpdb->query(
345 433 $wpdb->prepare(
346 434 "INSERT INTO {$this->decisions_table()}
347 435 (route_key, route, device, preload, unload, updated_at)
348 - VALUES (%s,%s,%s,%s,%s,%s)
436 + VALUES (%s,%s,%s,%s,%s,%s){$alias}
349 437 ON DUPLICATE KEY UPDATE
350 - preload = VALUES(preload),
351 - unload = VALUES(unload),
352 - updated_at = VALUES(updated_at)",
438 + preload = {$this->dup( 'preload' )},
439 + unload = {$this->dup( 'unload' )},
440 + updated_at = {$this->dup( 'updated_at' )}",
353 441 $key,
354 442 $route,
355 443 $device,
356 444 wp_json_encode( array_values( $preload ) ),
357 445 wp_json_encode( array_values( $unload ) ),
358 - current_time( 'mysql' )
446 + gmdate( 'Y-m-d H:i:s' ) // UTC — parsed as UTC in has_fresh_decision().
359 447 )
360 448 );
361 449 }
362 450
@@ -457,19 +545,20 @@
457 545 $days = max( 1, $days );
458 546 $usage = $this->usage_table();
459 547 $dec = $this->decisions_table();
460 548
461 - // 1. Time-based prune (usage + decisions).
549 + // 1. Time-based prune (usage + decisions). Timestamps are stored in
550 + // UTC, so compare against UTC_TIMESTAMP() (not session-TZ NOW()).
462 551 // phpcs:disable WordPress.DB.DirectDatabaseQuery
463 552 $wpdb->query(
464 553 $wpdb->prepare(
465 - "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( NOW() - INTERVAL %d DAY )",
554 + "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
466 555 $days
467 556 )
468 557 );
469 558 $wpdb->query(
470 559 $wpdb->prepare(
471 - "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( NOW() - INTERVAL %d DAY )",
560 + "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
472 561 $days
473 562 )
474 563 );
475 564 // phpcs:enable
@@ -474,8 +563,10 @@
474 563 );
475 564 // phpcs:enable
476 565
477 566 // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus.
567 + // The id-bounded form is deterministic (replication-safe), unlike a bare
568 + // DELETE ... ORDER BY ... LIMIT.
478 569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
479 570 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" );
480 571
481 572 if ( $total > $max_rows ) {
@@ -483,14 +574,40 @@
483 574
484 575 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
485 576 $wpdb->query(
486 577 $wpdb->prepare(
487 - "DELETE FROM {$usage} ORDER BY last_seen ASC LIMIT %d",
578 + "DELETE FROM {$usage} WHERE id IN (
579 + SELECT id FROM ( SELECT id FROM {$usage} ORDER BY last_seen ASC, id ASC LIMIT %d ) AS ef_tmp
580 + )",
488 581 $surplus
489 582 )
490 583 );
491 584 }
492 585
586 + // 3. Hard cap on decisions rows too — beacon routes are public input, so
587 + // without a cap the table could be grown without bound between prunes.
588 + /**
589 + * Hard cap on decision rows after time-based pruning.
590 + *
591 + * @param int $max
592 + */
593 + $max_dec = max( 100, (int) apply_filters( 'easyfonts_gc_max_decision_rows', 2000 ) );
594 +
595 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
596 + $total_dec = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$dec}" );
597 +
598 + if ( $total_dec > $max_dec ) {
599 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery
600 + $wpdb->query(
601 + $wpdb->prepare(
602 + "DELETE FROM {$dec} WHERE id IN (
603 + SELECT id FROM ( SELECT id FROM {$dec} ORDER BY updated_at ASC, id ASC LIMIT %d ) AS ef_tmp
604 + )",
605 + $total_dec - $max_dec
606 + )
607 + );
608 + }
609 +
493 610 // Pruning may have changed which families are uniformly above the fold.
494 611 $this->recompute_global_preload();
495 612 }
496 613
@@ -522,9 +639,54 @@
522 639 if ( ! $updated ) {
523 640 return false;
524 641 }
525 642
526 - return ( time() - strtotime( (string) $updated ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
643 + // Stored in UTC — parse it as UTC regardless of PHP's default timezone.
644 + return ( time() - strtotime( (string) $updated . ' UTC' ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
645 + }
646 +
647 + /**
648 + * ON DUPLICATE KEY UPDATE row reference, portable across servers.
649 + *
650 + * MySQL 8.0.20 deprecated VALUES() in ON DUPLICATE KEY UPDATE in favour of
651 + * a row alias — which MariaDB does not support. Detect the server once and
652 + * emit whichever syntax is native, so neither engine logs deprecation
653 + * warnings nor breaks when VALUES() is eventually removed.
654 + *
655 + * @param string $column Column name.
656 + * @return string SQL fragment referencing the incoming row's value.
657 + */
658 + private function dup( string $column ): string {
659 + return $this->use_row_alias() ? "ef_new.{$column}" : "VALUES({$column})";
660 + }
661 +
662 + /**
663 + * Row-alias suffix for the INSERT (empty when using VALUES()).
664 + *
665 + * @return string
666 + */
667 + private function dup_alias(): string {
668 + return $this->use_row_alias() ? ' AS ef_new' : '';
669 + }
670 +
671 + /**
672 + * Should upserts use the MySQL 8.0.19+ row-alias syntax?
673 + *
674 + * @return bool
675 + */
676 + private function use_row_alias(): bool {
677 + static $use = null;
678 +
679 + if ( null === $use ) {
680 + global $wpdb;
681 +
682 + $info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : '';
683 + $is_mariadb = false !== stripos( $info, 'mariadb' );
684 +
685 + $use = ! $is_mariadb && '' !== $info && version_compare( (string) $wpdb->db_version(), '8.0.19', '>=' );
686 + }
687 +
688 + return $use;
527 689 }
528 690
529 691 /**
530 692 * Empty both tables.