PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
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 8.2.1, at includes/cache/class-stats-image.php

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