PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.0
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / cache / class-stats-image.php

class-stats-image.php in ActivityPub 9.1.0, at includes/cache/class-stats-image.php

684 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stats Image cache class.
4 *
5 * @package Activitypub
6 * @since 8.1.0
7 */
8
9 namespace Activitypub\Cache;
10
11 use Activitypub\Collection\Actors;
12 use Activitypub\Model\Blog;
13 use Activitypub\Statistics;
14
15 /**
16 * Stats Image cache class.
17 *
18 * Generates, caches, and serves shareable stats images.
19 * Extends the File cache base class for storage, optimization, and cleanup.
20 * Images are stored in /wp-content/uploads/activitypub/stats/{user_id}/
21 */
22 class Stats_Image extends File {
23
24 /**
25 * Image width in pixels.
26 *
27 * @var int
28 */
29 const WIDTH = 1200;
30
31 /**
32 * Image height in pixels.
33 *
34 * @var int
35 */
36 const HEIGHT = 630;
37
38 /**
39 * Get the cache type identifier.
40 *
41 * @return string Cache type.
42 */
43 public static function get_type() {
44 return 'stats_image';
45 }
46
47 /**
48 * Get the base directory path relative to uploads.
49 *
50 * @return string Base directory path.
51 */
52 public static function get_base_dir() {
53 return '/activitypub/stats/';
54 }
55
56 /**
57 * Get the context identifier for the filter.
58 *
59 * @return string Context identifier.
60 */
61 public static function get_context() {
62 return 'stats_image';
63 }
64
65 /**
66 * Get the maximum dimension for images of this type.
67 *
68 * Stats images have a fixed size, so no resizing is needed.
69 *
70 * @return int Maximum width/height in pixels.
71 */
72 public static function get_max_dimension() {
73 return self::WIDTH;
74 }
75
76 /**
77 * Check if the GD library is available.
78 *
79 * @return bool Whether GD is available.
80 */
81 public static function is_available() {
82 return \function_exists( 'imagecreatetruecolor' );
83 }
84
85 /**
86 * Get the public URL for a stats image, generating it if needed.
87 *
88 * @param int $user_id The user ID.
89 * @param int $year The year.
90 *
91 * @return string|\WP_Error The public URL or error.
92 */
93 public static function get_url( $user_id, $year ) {
94 if ( ! self::is_available() ) {
95 return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
96 }
97
98 // If local caching is disabled, use the REST endpoint for on-the-fly generation.
99 if ( ! static::is_enabled() ) {
100 $url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
101
102 /**
103 * Filters the stats image URL.
104 *
105 * Can be used to route through a CDN or image proxy like Photon.
106 *
107 * @since 8.1.0
108 *
109 * @param string $url The image URL.
110 * @param int $user_id The user ID.
111 * @param int $year The year.
112 */
113 return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
114 }
115
116 $hash = self::get_hash( $user_id, $year );
117 $paths = static::get_storage_paths( $user_id );
118
119 // Check for cached file using the base class glob pattern.
120 $pattern = static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' . $hash ) . '.*';
121 $matches = \glob( $pattern );
122
123 if ( ! empty( $matches ) && \is_file( $matches[0] ) ) {
124 $url = $paths['baseurl'] . '/' . \basename( $matches[0] );
125
126 /** This filter is documented in includes/cache/class-stats-image.php */
127 return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
128 }
129
130 // Generate the image.
131 $result = self::generate( $user_id, $year );
132
133 if ( \is_wp_error( $result ) ) {
134 return $result;
135 }
136
137 $url = $paths['baseurl'] . '/' . \basename( $result );
138
139 /** This filter is documented in includes/cache/class-stats-image.php */
140 return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
141 }
142
143 /**
144 * Serve a stats image, generating it if needed.
145 *
146 * Outputs headers and image data, then exits.
147 *
148 * @param int $user_id The user ID.
149 * @param int $year The year.
150 *
151 * @return \WP_Error|void Error on failure, exits on success.
152 */
153 public static function serve( $user_id, $year ) {
154 if ( ! self::is_available() ) {
155 return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
156 }
157
158 $hash = self::get_hash( $user_id, $year );
159 $paths = static::get_storage_paths( $user_id );
160
161 // Check for cached file.
162 $pattern = static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' . $hash ) . '.*';
163 $matches = \glob( $pattern );
164 $file = ( ! empty( $matches ) && \is_file( $matches[0] ) ) ? $matches[0] : null;
165
166 if ( ! $file ) {
167 $file = self::generate( $user_id, $year );
168 }
169
170 if ( \is_wp_error( $file ) ) {
171 return $file;
172 }
173
174 $mime_type = static::get_file_mime_type( $file );
175
176 \header( 'Content-Type: ' . ( $mime_type ?: 'image/png' ) );
177 \header( 'Content-Length: ' . \filesize( $file ) );
178 \header( 'Cache-Control: public, max-age=86400' );
179 \header( 'X-Content-Type-Options: nosniff' );
180
181 \readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
182 exit;
183 }
184
185 /**
186 * Generate the stats image and save to cache.
187 *
188 * @param int $user_id The user ID.
189 * @param int $year The year.
190 *
191 * @return string|\WP_Error Cached file path or error.
192 */
193 public static function generate( $user_id, $year ) {
194 if ( ! self::is_available() ) {
195 return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
196 }
197
198 $summary = Statistics::get_annual_summary( $user_id, $year );
199
200 if ( ! $summary ) {
201 $summary = Statistics::compile_annual_summary( $user_id, $year );
202 }
203
204 if ( ! $summary || empty( $summary['posts_count'] ) ) {
205 return new \WP_Error( 'no_stats', \__( 'No statistics available for this period.', 'activitypub' ), array( 'status' => 404 ) );
206 }
207
208 $actor = Actors::get_by_id( $user_id );
209
210 if ( \is_wp_error( $actor ) && Actors::BLOG_USER_ID === $user_id ) {
211 $actor = new Blog();
212 }
213
214 if ( ! \is_wp_error( $actor ) ) {
215 $actor_webfinger = $actor->get_webfinger();
216 } else {
217 $actor_webfinger = '';
218 }
219 $site_name = \get_bloginfo( 'name' );
220
221 if ( ! \function_exists( 'wp_tempnam' ) ) {
222 require_once ABSPATH . 'wp-admin/includes/file.php';
223 }
224
225 $tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year );
226
227 if ( \is_wp_error( $tmp_file ) ) {
228 return $tmp_file;
229 }
230
231 // Use the base class storage paths and optimization.
232 $paths = static::get_storage_paths( $user_id );
233
234 if ( ! \wp_mkdir_p( $paths['basedir'] ) ) {
235 \wp_delete_file( $tmp_file );
236 return new \WP_Error( 'cache_dir_failed', \__( 'Failed to create cache directory.', 'activitypub' ), array( 'status' => 500 ) );
237 }
238
239 // Remove old cached images for this year before saving the new one.
240 $old_files = \glob( static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' ) . '*.*' );
241 if ( $old_files ) {
242 foreach ( $old_files as $old_file ) {
243 \wp_delete_file( $old_file );
244 }
245 }
246
247 $hash = self::get_hash( $user_id, $year );
248 $dest_name = \sprintf( 'stats-%d-%s.png', $year, $hash );
249 $dest_path = $paths['basedir'] . '/' . $dest_name;
250
251 static::get_filesystem()->move( $tmp_file, $dest_path, true );
252
253 // Keep as PNG for maximum compatibility when sharing on social networks.
254 return $dest_path;
255 }
256
257 /**
258 * Generate a hash for cache invalidation.
259 *
260 * Includes the theme stylesheet, version, and stats compilation
261 * timestamp so cached images are regenerated when the theme or
262 * the underlying stats data changes.
263 *
264 * @param int $user_id The user ID.
265 * @param int $year The year.
266 *
267 * @return string The hash string.
268 */
269 private static function get_hash( $user_id = 0, $year = 0 ) {
270 $parts = array(
271 \get_stylesheet(),
272 \wp_get_theme()->get( 'Version' ),
273 );
274
275 if ( $user_id && $year ) {
276 $summary = Statistics::get_annual_summary( $user_id, $year );
277
278 if ( $summary && ! empty( $summary['compiled_at'] ) ) {
279 $parts[] = $summary['compiled_at'];
280 }
281 }
282
283 return \md5( \wp_json_encode( $parts ) );
284 }
285
286 /**
287 * Render the stats image as a temporary PNG file.
288 *
289 * @param array $summary The annual stats summary.
290 * @param string $actor_webfinger The actor webfinger identifier.
291 * @param string $site_name The site name.
292 * @param int $year The year.
293 * @return string|\WP_Error Path to temporary PNG file or error.
294 */
295 private static function render( $summary, $actor_webfinger, $site_name, $year ) {
296 $width = self::WIDTH;
297 $height = self::HEIGHT;
298
299 $image = \imagecreatetruecolor( $width, $height );
300
301 if ( ! $image ) {
302 return new \WP_Error( 'image_create_failed', \__( 'Failed to create image.', 'activitypub' ), array( 'status' => 500 ) );
303 }
304
305 \imageantialias( $image, true );
306
307 $colors = self::resolve_colors();
308 $bg = \imagecolorallocate( $image, $colors['bg'][0], $colors['bg'][1], $colors['bg'][2] );
309 $fg = \imagecolorallocate( $image, $colors['fg'][0], $colors['fg'][1], $colors['fg'][2] );
310 $muted = \imagecolorallocate( $image, $colors['muted'][0], $colors['muted'][1], $colors['muted'][2] );
311
312 \imagefill( $image, 0, 0, $bg );
313
314 $font = self::resolve_font();
315
316 // Total engagement.
317 $comment_types = Statistics::get_comment_types_for_stats();
318 $total_engagement = 0;
319 foreach ( \array_keys( $comment_types ) as $slug ) {
320 $total_engagement += $summary[ $slug . '_count' ] ?? 0;
321 }
322
323 // Title.
324 $title = \sprintf(
325 /* translators: %d: The year */
326 \__( 'Fediverse Stats %d', 'activitypub' ),
327 $year
328 );
329 self::draw_text( $image, $title, null, 100, 36, $fg, $font );
330
331 // Actor webfinger.
332 if ( $actor_webfinger ) {
333 self::draw_text( $image, $actor_webfinger, null, 150, 20, $muted, $font );
334 }
335
336 // Three big stats in a row.
337 $stats = array(
338 array(
339 'value' => \number_format_i18n( $summary['posts_count'] ),
340 'label' => \__( 'Posts', 'activitypub' ),
341 ),
342 array(
343 'value' => \number_format_i18n( $total_engagement ),
344 'label' => \__( 'Engagements', 'activitypub' ),
345 ),
346 array(
347 'value' => \number_format_i18n( $summary['followers_end'] ?? 0 ),
348 'label' => \__( 'Followers', 'activitypub' ),
349 ),
350 );
351
352 $col_width = (int) ( $width / 3 );
353
354 foreach ( $stats as $i => $stat ) {
355 $center_x = (int) ( $col_width * $i + $col_width / 2 );
356 self::draw_text( $image, $stat['value'], $center_x, 300, 56, $fg, $font );
357 self::draw_text( $image, $stat['label'], $center_x, 355, 18, $muted, $font );
358 }
359
360 // Follower growth line.
361 $followers_net = $summary['followers_net_change'] ?? 0;
362 $change_sign = $followers_net >= 0 ? '+' : '';
363 $growth_text = \sprintf(
364 /* translators: %s: follower net change */
365 \__( '%s followers this year', 'activitypub' ),
366 $change_sign . \number_format_i18n( $followers_net )
367 );
368 self::draw_text( $image, $growth_text, null, 450, 20, $muted, $font );
369
370 // Branding.
371 $branding = $site_name . ' - ' . \__( 'Powered by ActivityPub', 'activitypub' );
372 self::draw_text( $image, $branding, null, $height - 40, 14, $muted, $font );
373
374 // Save to temp file.
375 $tmp_file = \wp_tempnam( 'activitypub-stats-' );
376
377 if ( ! $tmp_file ) {
378 return new \WP_Error( 'temp_file_failed', \__( 'Could not create temporary file.', 'activitypub' ), array( 'status' => 500 ) );
379 }
380
381 $saved = \imagepng( $image, $tmp_file );
382
383 // imagedestroy() is deprecated since PHP 8.5 and a no-op since 8.0.
384 if ( \PHP_VERSION_ID < 80000 ) {
385 \imagedestroy( $image );
386 }
387
388 if ( ! $saved ) {
389 \wp_delete_file( $tmp_file );
390 return new \WP_Error( 'image_write_failed', \__( 'Failed to write stats image.', 'activitypub' ), array( 'status' => 500 ) );
391 }
392
393 return $tmp_file;
394 }
395
396 /**
397 * Draw text on the image, centered on the canvas or at a specific x position.
398 *
399 * Uses TrueType rendering when a font is available, falls back to
400 * GD built-in fonts.
401 *
402 * @param resource $image The image resource.
403 * @param string $text The text to draw.
404 * @param int|null $x The center x position, or null to center on canvas.
405 * @param int $y The y position.
406 * @param int|float $size Font size in points (TTF) or 1-5 (built-in).
407 * @param int $color The text color.
408 * @param string|false $font Path to TTF file, or false for built-in.
409 */
410 private static function draw_text( $image, $text, $x, $y, $size, $color, $font = false ) {
411 if ( $font && \function_exists( 'imagefttext' ) ) {
412 $bbox = \imageftbbox( $size, 0, $font, $text );
413 $text_width = $bbox[2] - $bbox[0];
414 $draw_x = null === $x
415 ? (int) ( ( self::WIDTH - $text_width ) / 2 )
416 : (int) ( $x - $text_width / 2 );
417 \imagefttext( $image, $size, 0, $draw_x, $y, $color, $font, $text );
418 } else {
419 $builtin_size = \min( 5, \max( 1, (int) ( $size / 10 ) ) );
420 $font_width = \imagefontwidth( $builtin_size );
421 $text_width = $font_width * \strlen( $text );
422 $draw_x = null === $x
423 ? (int) ( ( self::WIDTH - $text_width ) / 2 )
424 : (int) ( $x - $text_width / 2 );
425 \imagestring( $image, $builtin_size, $draw_x, $y, $text, $color );
426 }
427 }
428
429 /**
430 * Resolve colors from theme Global Styles or overrides.
431 *
432 * @return array Associative array with 'bg', 'fg', and 'muted' RGB arrays.
433 */
434 private static function resolve_colors() {
435 $bg_rgb = array( 255, 255, 255 );
436 $fg_rgb = array( 17, 17, 17 );
437
438 $palette = array();
439 $settings = \wp_get_global_settings();
440 if ( ! empty( $settings['color']['palette'] ) ) {
441 foreach ( $settings['color']['palette'] as $colors ) {
442 foreach ( $colors as $color ) {
443 $palette[ $color['slug'] ] = $color['color'];
444 }
445 }
446 }
447
448 $styles = \wp_get_global_styles( array( 'color' ) );
449 $bg_resolved = self::resolve_style_color( $styles['background'] ?? '', $palette );
450 $fg_resolved = self::resolve_style_color( $styles['text'] ?? '', $palette );
451
452 if ( $bg_resolved ) {
453 $bg_rgb = $bg_resolved;
454 }
455
456 if ( $fg_resolved ) {
457 $fg_rgb = $fg_resolved;
458 }
459
460 if ( ! $bg_resolved || ! $fg_resolved ) {
461 $bg_slugs = array( 'base', 'background', 'white' );
462 $fg_slugs = array( 'contrast', 'foreground', 'black', 'dark-gray' );
463
464 if ( ! $bg_resolved ) {
465 foreach ( $bg_slugs as $slug ) {
466 if ( ! empty( $palette[ $slug ] ) ) {
467 $parsed = self::parse_hex( $palette[ $slug ] );
468 if ( $parsed ) {
469 $bg_rgb = $parsed;
470 break;
471 }
472 }
473 }
474 }
475
476 if ( ! $fg_resolved ) {
477 foreach ( $fg_slugs as $slug ) {
478 if ( ! empty( $palette[ $slug ] ) ) {
479 $parsed = self::parse_hex( $palette[ $slug ] );
480 if ( $parsed ) {
481 $fg_rgb = $parsed;
482 break;
483 }
484 }
485 }
486 }
487 }
488
489 return self::build_color_set( $bg_rgb, $fg_rgb );
490 }
491
492 /**
493 * Build a color set with a derived muted color.
494 *
495 * @param array $bg_rgb Background RGB.
496 * @param array $fg_rgb Foreground RGB.
497 *
498 * @return array { bg, fg, muted } RGB arrays.
499 */
500 private static function build_color_set( $bg_rgb, $fg_rgb ) {
501 return array(
502 'bg' => $bg_rgb,
503 'fg' => $fg_rgb,
504 'muted' => array(
505 (int) ( ( $fg_rgb[0] + $bg_rgb[0] ) / 2 ),
506 (int) ( ( $fg_rgb[1] + $bg_rgb[1] ) / 2 ),
507 (int) ( ( $fg_rgb[2] + $bg_rgb[2] ) / 2 ),
508 ),
509 );
510 }
511
512 /**
513 * Resolve a color value from Global Styles.
514 *
515 * @param string $value The color value (hex or CSS variable).
516 * @param array $palette The merged color palette (slug => hex).
517 *
518 * @return array|false RGB array or false.
519 */
520 private static function resolve_style_color( $value, $palette ) {
521 if ( empty( $value ) ) {
522 return false;
523 }
524
525 if ( '#' === $value[0] ) {
526 return self::parse_hex( $value );
527 }
528
529 if ( \preg_match( '/--color--([a-z0-9-]+)/', $value, $matches ) ) {
530 if ( ! empty( $palette[ $matches[1] ] ) ) {
531 return self::parse_hex( $palette[ $matches[1] ] );
532 }
533 }
534
535 return false;
536 }
537
538 /**
539 * Parse a hex color string into an RGB array.
540 *
541 * @param string $hex The hex color (e.g. '#FF0000' or '#F00').
542 *
543 * @return array|false Array of [r, g, b] or false on failure.
544 */
545 private static function parse_hex( $hex ) {
546 $hex = \ltrim( $hex, '#' );
547
548 if ( 3 === \strlen( $hex ) ) {
549 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
550 }
551
552 if ( 6 !== \strlen( $hex ) ) {
553 return false;
554 }
555
556 $result = \sscanf( $hex, '%02x%02x%02x' );
557
558 return ( 3 === \count( $result ) ) ? $result : false;
559 }
560
561 /**
562 * Resolve a TTF font file from the active theme or Font Library.
563 *
564 * @return string|false Path to a TTF file, or false if none found.
565 */
566 private static function resolve_font() {
567 $body_slug = '';
568 $styles = \wp_get_global_styles( array( 'typography' ) );
569 if ( ! empty( $styles['fontFamily'] ) && \preg_match( '/--font-family--([a-z0-9-]+)/', $styles['fontFamily'], $matches ) ) {
570 $body_slug = $matches[1];
571 }
572
573 $settings = \wp_get_global_settings();
574 if ( ! empty( $settings['typography']['fontFamilies'] ) ) {
575 $all_families = array();
576 foreach ( $settings['typography']['fontFamilies'] as $families ) {
577 foreach ( $families as $family ) {
578 $all_families[] = $family;
579 }
580 }
581
582 // Sort so the body font family is tried first.
583 if ( $body_slug ) {
584 \usort(
585 $all_families,
586 function ( $a, $b ) use ( $body_slug ) {
587 return ( ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1 ) - ( ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1 );
588 }
589 );
590 }
591
592 $font = self::find_ttf_in_families( $all_families );
593 if ( $font ) {
594 return $font;
595 }
596 }
597
598 // Try the Font Library (WP 6.5+).
599 $font = self::find_ttf_in_font_library();
600 if ( $font ) {
601 return $font;
602 }
603
604 return false;
605 }
606
607 /**
608 * Find a TTF/OTF file in font family definitions.
609 *
610 * @param array $families The font families to search.
611 *
612 * @return string|false Path to TTF file or false.
613 */
614 private static function find_ttf_in_families( $families ) {
615 $theme_dir = \get_theme_root();
616
617 foreach ( $families as $family ) {
618 if ( empty( $family['fontFace'] ) ) {
619 continue;
620 }
621 foreach ( $family['fontFace'] as $face ) {
622 $src = \is_array( $face['src'] ) ? $face['src'][0] : $face['src'];
623
624 if ( ! \preg_match( '/\.(ttf|otf)$/i', $src ) ) {
625 continue;
626 }
627
628 // Resolve theme-relative paths.
629 if ( 0 === \strpos( $src, 'file:./' ) ) {
630 $src = \get_theme_file_path( \substr( $src, 7 ) );
631 }
632
633 // Only allow fonts within the themes directory for security.
634 $real_path = \realpath( $src );
635 if ( ! $real_path || 0 !== \strpos( $real_path, \realpath( $theme_dir ) ) ) {
636 continue;
637 }
638
639 return $real_path;
640 }
641 }
642
643 return false;
644 }
645
646 /**
647 * Find a TTF/OTF file from the WordPress Font Library.
648 *
649 * @return string|false Path to TTF file or false.
650 */
651 private static function find_ttf_in_font_library() {
652 $font_families = \get_posts(
653 array(
654 'post_type' => 'wp_font_family',
655 'posts_per_page' => 10,
656 'post_status' => 'publish',
657 )
658 );
659
660 foreach ( $font_families as $font_family ) {
661 $faces = \get_posts(
662 array(
663 'post_type' => 'wp_font_face',
664 'post_parent' => $font_family->ID,
665 'posts_per_page' => 10,
666 'post_status' => 'publish',
667 )
668 );
669
670 foreach ( $faces as $face ) {
671 $file = \get_post_meta( $face->ID, '_wp_font_face_file', true );
672 if ( $file && \preg_match( '/\.(ttf|otf)$/i', $file ) ) {
673 $path = \path_join( \wp_get_font_dir()['path'], $file );
674 if ( \file_exists( $path ) ) {
675 return $path;
676 }
677 }
678 }
679 }
680
681 return false;
682 }
683 }
684