updated plugin ActivityPub version 8.3.0

This commit is contained in:
2026-06-03 21:28:46 +00:00
committed by Gitium
parent a4b78ec277
commit 6fe182458a
340 changed files with 43232 additions and 7568 deletions

View File

@ -0,0 +1,684 @@
<?php
/**
* Stats Image cache class.
*
* @package Activitypub
* @since 8.1.0
*/
namespace Activitypub\Cache;
use Activitypub\Collection\Actors;
use Activitypub\Model\Application;
use Activitypub\Model\Blog;
use Activitypub\Statistics;
/**
* Stats Image cache class.
*
* Generates, caches, and serves shareable stats images.
* Extends the File cache base class for storage, optimization, and cleanup.
* Images are stored in /wp-content/uploads/activitypub/stats/{user_id}/
*/
class Stats_Image extends File {
/**
* Image width in pixels.
*
* @var int
*/
const WIDTH = 1200;
/**
* Image height in pixels.
*
* @var int
*/
const HEIGHT = 630;
/**
* Get the cache type identifier.
*
* @return string Cache type.
*/
public static function get_type() {
return 'stats_image';
}
/**
* Get the base directory path relative to uploads.
*
* @return string Base directory path.
*/
public static function get_base_dir() {
return '/activitypub/stats/';
}
/**
* Get the context identifier for the filter.
*
* @return string Context identifier.
*/
public static function get_context() {
return 'stats_image';
}
/**
* Get the maximum dimension for images of this type.
*
* Stats images have a fixed size, so no resizing is needed.
*
* @return int Maximum width/height in pixels.
*/
public static function get_max_dimension() {
return self::WIDTH;
}
/**
* Check if the GD library is available.
*
* @return bool Whether GD is available.
*/
public static function is_available() {
return \function_exists( 'imagecreatetruecolor' );
}
/**
* Get the public URL for a stats image, generating it if needed.
*
* @param int $user_id The user ID.
* @param int $year The year.
*
* @return string|\WP_Error The public URL or error.
*/
public static function get_url( $user_id, $year ) {
if ( ! self::is_available() ) {
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
}
// If local caching is disabled, use the REST endpoint for on-the-fly generation.
if ( ! static::is_enabled() ) {
$url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
/**
* Filters the stats image URL.
*
* Can be used to route through a CDN or image proxy like Photon.
*
* @since 8.1.0
*
* @param string $url The image URL.
* @param int $user_id The user ID.
* @param int $year The year.
*/
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
}
$hash = self::get_hash( $user_id, $year );
$paths = static::get_storage_paths( $user_id );
// Check for cached file using the base class glob pattern.
$pattern = static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' . $hash ) . '.*';
$matches = \glob( $pattern );
if ( ! empty( $matches ) && \is_file( $matches[0] ) ) {
$url = $paths['baseurl'] . '/' . \basename( $matches[0] );
/** This filter is documented in includes/cache/class-stats-image.php */
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
}
// Generate the image.
$result = self::generate( $user_id, $year );
if ( \is_wp_error( $result ) ) {
return $result;
}
$url = $paths['baseurl'] . '/' . \basename( $result );
/** This filter is documented in includes/cache/class-stats-image.php */
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
}
/**
* Serve a stats image, generating it if needed.
*
* Outputs headers and image data, then exits.
*
* @param int $user_id The user ID.
* @param int $year The year.
*
* @return \WP_Error|void Error on failure, exits on success.
*/
public static function serve( $user_id, $year ) {
if ( ! self::is_available() ) {
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
}
$hash = self::get_hash( $user_id, $year );
$paths = static::get_storage_paths( $user_id );
// Check for cached file.
$pattern = static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' . $hash ) . '.*';
$matches = \glob( $pattern );
$file = ( ! empty( $matches ) && \is_file( $matches[0] ) ) ? $matches[0] : null;
if ( ! $file ) {
$file = self::generate( $user_id, $year );
}
if ( \is_wp_error( $file ) ) {
return $file;
}
$mime_type = static::get_file_mime_type( $file );
\header( 'Content-Type: ' . ( $mime_type ?: 'image/png' ) );
\header( 'Content-Length: ' . \filesize( $file ) );
\header( 'Cache-Control: public, max-age=86400' );
\header( 'X-Content-Type-Options: nosniff' );
\readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
exit;
}
/**
* Generate the stats image and save to cache.
*
* @param int $user_id The user ID.
* @param int $year The year.
*
* @return string|\WP_Error Cached file path or error.
*/
public static function generate( $user_id, $year ) {
if ( ! self::is_available() ) {
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
}
$summary = Statistics::get_annual_summary( $user_id, $year );
if ( ! $summary ) {
$summary = Statistics::compile_annual_summary( $user_id, $year );
}
if ( ! $summary || empty( $summary['posts_count'] ) ) {
return new \WP_Error( 'no_stats', \__( 'No statistics available for this period.', 'activitypub' ), array( 'status' => 404 ) );
}
$actor = Actors::get_by_id( $user_id );
if ( \is_wp_error( $actor ) ) {
if ( Actors::BLOG_USER_ID === $user_id ) {
$actor = new Blog();
} elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
$actor = new Application();
}
}
$actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
$site_name = \get_bloginfo( 'name' );
if ( ! \function_exists( 'wp_tempnam' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year );
if ( \is_wp_error( $tmp_file ) ) {
return $tmp_file;
}
// Use the base class storage paths and optimization.
$paths = static::get_storage_paths( $user_id );
if ( ! \wp_mkdir_p( $paths['basedir'] ) ) {
\wp_delete_file( $tmp_file );
return new \WP_Error( 'cache_dir_failed', \__( 'Failed to create cache directory.', 'activitypub' ), array( 'status' => 500 ) );
}
// Remove old cached images for this year before saving the new one.
$old_files = \glob( static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' ) . '*.*' );
if ( $old_files ) {
foreach ( $old_files as $old_file ) {
\wp_delete_file( $old_file );
}
}
$hash = self::get_hash( $user_id, $year );
$dest_name = \sprintf( 'stats-%d-%s.png', $year, $hash );
$dest_path = $paths['basedir'] . '/' . $dest_name;
static::get_filesystem()->move( $tmp_file, $dest_path, true );
// Keep as PNG for maximum compatibility when sharing on social networks.
return $dest_path;
}
/**
* Generate a hash for cache invalidation.
*
* Includes the theme stylesheet, version, and stats compilation
* timestamp so cached images are regenerated when the theme or
* the underlying stats data changes.
*
* @param int $user_id The user ID.
* @param int $year The year.
*
* @return string The hash string.
*/
private static function get_hash( $user_id = 0, $year = 0 ) {
$parts = array(
\get_stylesheet(),
\wp_get_theme()->get( 'Version' ),
);
if ( $user_id && $year ) {
$summary = Statistics::get_annual_summary( $user_id, $year );
if ( $summary && ! empty( $summary['compiled_at'] ) ) {
$parts[] = $summary['compiled_at'];
}
}
return \md5( \wp_json_encode( $parts ) );
}
/**
* Render the stats image as a temporary PNG file.
*
* @param array $summary The annual stats summary.
* @param string $actor_webfinger The actor webfinger identifier.
* @param string $site_name The site name.
* @param int $year The year.
* @return string|\WP_Error Path to temporary PNG file or error.
*/
private static function render( $summary, $actor_webfinger, $site_name, $year ) {
$width = self::WIDTH;
$height = self::HEIGHT;
$image = \imagecreatetruecolor( $width, $height );
if ( ! $image ) {
return new \WP_Error( 'image_create_failed', \__( 'Failed to create image.', 'activitypub' ), array( 'status' => 500 ) );
}
\imageantialias( $image, true );
$colors = self::resolve_colors();
$bg = \imagecolorallocate( $image, $colors['bg'][0], $colors['bg'][1], $colors['bg'][2] );
$fg = \imagecolorallocate( $image, $colors['fg'][0], $colors['fg'][1], $colors['fg'][2] );
$muted = \imagecolorallocate( $image, $colors['muted'][0], $colors['muted'][1], $colors['muted'][2] );
\imagefill( $image, 0, 0, $bg );
$font = self::resolve_font();
// Total engagement.
$comment_types = Statistics::get_comment_types_for_stats();
$total_engagement = 0;
foreach ( \array_keys( $comment_types ) as $slug ) {
$total_engagement += $summary[ $slug . '_count' ] ?? 0;
}
// Title.
$title = \sprintf(
/* translators: %d: The year */
\__( 'Fediverse Stats %d', 'activitypub' ),
$year
);
self::draw_text( $image, $title, null, 100, 36, $fg, $font );
// Actor webfinger.
if ( $actor_webfinger ) {
self::draw_text( $image, $actor_webfinger, null, 150, 20, $muted, $font );
}
// Three big stats in a row.
$stats = array(
array(
'value' => \number_format_i18n( $summary['posts_count'] ),
'label' => \__( 'Posts', 'activitypub' ),
),
array(
'value' => \number_format_i18n( $total_engagement ),
'label' => \__( 'Engagements', 'activitypub' ),
),
array(
'value' => \number_format_i18n( $summary['followers_end'] ?? 0 ),
'label' => \__( 'Followers', 'activitypub' ),
),
);
$col_width = (int) ( $width / 3 );
foreach ( $stats as $i => $stat ) {
$center_x = (int) ( $col_width * $i + $col_width / 2 );
self::draw_text( $image, $stat['value'], $center_x, 300, 56, $fg, $font );
self::draw_text( $image, $stat['label'], $center_x, 355, 18, $muted, $font );
}
// Follower growth line.
$followers_net = $summary['followers_net_change'] ?? 0;
$change_sign = $followers_net >= 0 ? '+' : '';
$growth_text = \sprintf(
/* translators: %s: follower net change */
\__( '%s followers this year', 'activitypub' ),
$change_sign . \number_format_i18n( $followers_net )
);
self::draw_text( $image, $growth_text, null, 450, 20, $muted, $font );
// Branding.
$branding = $site_name . ' - ' . \__( 'Powered by ActivityPub', 'activitypub' );
self::draw_text( $image, $branding, null, $height - 40, 14, $muted, $font );
// Save to temp file.
$tmp_file = \wp_tempnam( 'activitypub-stats-' );
if ( ! $tmp_file ) {
return new \WP_Error( 'temp_file_failed', \__( 'Could not create temporary file.', 'activitypub' ), array( 'status' => 500 ) );
}
$saved = \imagepng( $image, $tmp_file );
// imagedestroy() is deprecated since PHP 8.5 and a no-op since 8.0.
if ( \PHP_VERSION_ID < 80000 ) {
\imagedestroy( $image );
}
if ( ! $saved ) {
\wp_delete_file( $tmp_file );
return new \WP_Error( 'image_write_failed', \__( 'Failed to write stats image.', 'activitypub' ), array( 'status' => 500 ) );
}
return $tmp_file;
}
/**
* Draw text on the image, centered on the canvas or at a specific x position.
*
* Uses TrueType rendering when a font is available, falls back to
* GD built-in fonts.
*
* @param resource $image The image resource.
* @param string $text The text to draw.
* @param int|null $x The center x position, or null to center on canvas.
* @param int $y The y position.
* @param int|float $size Font size in points (TTF) or 1-5 (built-in).
* @param int $color The text color.
* @param string|false $font Path to TTF file, or false for built-in.
*/
private static function draw_text( $image, $text, $x, $y, $size, $color, $font = false ) {
if ( $font && \function_exists( 'imagefttext' ) ) {
$bbox = \imageftbbox( $size, 0, $font, $text );
$text_width = $bbox[2] - $bbox[0];
$draw_x = null === $x
? (int) ( ( self::WIDTH - $text_width ) / 2 )
: (int) ( $x - $text_width / 2 );
\imagefttext( $image, $size, 0, $draw_x, $y, $color, $font, $text );
} else {
$builtin_size = \min( 5, \max( 1, (int) ( $size / 10 ) ) );
$font_width = \imagefontwidth( $builtin_size );
$text_width = $font_width * \strlen( $text );
$draw_x = null === $x
? (int) ( ( self::WIDTH - $text_width ) / 2 )
: (int) ( $x - $text_width / 2 );
\imagestring( $image, $builtin_size, $draw_x, $y, $text, $color );
}
}
/**
* Resolve colors from theme Global Styles or overrides.
*
* @return array Associative array with 'bg', 'fg', and 'muted' RGB arrays.
*/
private static function resolve_colors() {
$bg_rgb = array( 255, 255, 255 );
$fg_rgb = array( 17, 17, 17 );
$palette = array();
$settings = \wp_get_global_settings();
if ( ! empty( $settings['color']['palette'] ) ) {
foreach ( $settings['color']['palette'] as $colors ) {
foreach ( $colors as $color ) {
$palette[ $color['slug'] ] = $color['color'];
}
}
}
$styles = \wp_get_global_styles( array( 'color' ) );
$bg_resolved = self::resolve_style_color( $styles['background'] ?? '', $palette );
$fg_resolved = self::resolve_style_color( $styles['text'] ?? '', $palette );
if ( $bg_resolved ) {
$bg_rgb = $bg_resolved;
}
if ( $fg_resolved ) {
$fg_rgb = $fg_resolved;
}
if ( ! $bg_resolved || ! $fg_resolved ) {
$bg_slugs = array( 'base', 'background', 'white' );
$fg_slugs = array( 'contrast', 'foreground', 'black', 'dark-gray' );
if ( ! $bg_resolved ) {
foreach ( $bg_slugs as $slug ) {
if ( ! empty( $palette[ $slug ] ) ) {
$parsed = self::parse_hex( $palette[ $slug ] );
if ( $parsed ) {
$bg_rgb = $parsed;
break;
}
}
}
}
if ( ! $fg_resolved ) {
foreach ( $fg_slugs as $slug ) {
if ( ! empty( $palette[ $slug ] ) ) {
$parsed = self::parse_hex( $palette[ $slug ] );
if ( $parsed ) {
$fg_rgb = $parsed;
break;
}
}
}
}
}
return self::build_color_set( $bg_rgb, $fg_rgb );
}
/**
* Build a color set with a derived muted color.
*
* @param array $bg_rgb Background RGB.
* @param array $fg_rgb Foreground RGB.
*
* @return array { bg, fg, muted } RGB arrays.
*/
private static function build_color_set( $bg_rgb, $fg_rgb ) {
return array(
'bg' => $bg_rgb,
'fg' => $fg_rgb,
'muted' => array(
(int) ( ( $fg_rgb[0] + $bg_rgb[0] ) / 2 ),
(int) ( ( $fg_rgb[1] + $bg_rgb[1] ) / 2 ),
(int) ( ( $fg_rgb[2] + $bg_rgb[2] ) / 2 ),
),
);
}
/**
* Resolve a color value from Global Styles.
*
* @param string $value The color value (hex or CSS variable).
* @param array $palette The merged color palette (slug => hex).
*
* @return array|false RGB array or false.
*/
private static function resolve_style_color( $value, $palette ) {
if ( empty( $value ) ) {
return false;
}
if ( '#' === $value[0] ) {
return self::parse_hex( $value );
}
if ( \preg_match( '/--color--([a-z0-9-]+)/', $value, $matches ) ) {
if ( ! empty( $palette[ $matches[1] ] ) ) {
return self::parse_hex( $palette[ $matches[1] ] );
}
}
return false;
}
/**
* Parse a hex color string into an RGB array.
*
* @param string $hex The hex color (e.g. '#FF0000' or '#F00').
*
* @return array|false Array of [r, g, b] or false on failure.
*/
private static function parse_hex( $hex ) {
$hex = \ltrim( $hex, '#' );
if ( 3 === \strlen( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( 6 !== \strlen( $hex ) ) {
return false;
}
$result = \sscanf( $hex, '%02x%02x%02x' );
return ( 3 === \count( $result ) ) ? $result : false;
}
/**
* Resolve a TTF font file from the active theme or Font Library.
*
* @return string|false Path to a TTF file, or false if none found.
*/
private static function resolve_font() {
$body_slug = '';
$styles = \wp_get_global_styles( array( 'typography' ) );
if ( ! empty( $styles['fontFamily'] ) && \preg_match( '/--font-family--([a-z0-9-]+)/', $styles['fontFamily'], $matches ) ) {
$body_slug = $matches[1];
}
$settings = \wp_get_global_settings();
if ( ! empty( $settings['typography']['fontFamilies'] ) ) {
$all_families = array();
foreach ( $settings['typography']['fontFamilies'] as $families ) {
foreach ( $families as $family ) {
$all_families[] = $family;
}
}
// Sort so the body font family is tried first.
if ( $body_slug ) {
\usort(
$all_families,
function ( $a, $b ) use ( $body_slug ) {
return ( ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1 ) - ( ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1 );
}
);
}
$font = self::find_ttf_in_families( $all_families );
if ( $font ) {
return $font;
}
}
// Try the Font Library (WP 6.5+).
$font = self::find_ttf_in_font_library();
if ( $font ) {
return $font;
}
return false;
}
/**
* Find a TTF/OTF file in font family definitions.
*
* @param array $families The font families to search.
*
* @return string|false Path to TTF file or false.
*/
private static function find_ttf_in_families( $families ) {
$theme_dir = \get_theme_root();
foreach ( $families as $family ) {
if ( empty( $family['fontFace'] ) ) {
continue;
}
foreach ( $family['fontFace'] as $face ) {
$src = \is_array( $face['src'] ) ? $face['src'][0] : $face['src'];
if ( ! \preg_match( '/\.(ttf|otf)$/i', $src ) ) {
continue;
}
// Resolve theme-relative paths.
if ( 0 === \strpos( $src, 'file:./' ) ) {
$src = \get_theme_file_path( \substr( $src, 7 ) );
}
// Only allow fonts within the themes directory for security.
$real_path = \realpath( $src );
if ( ! $real_path || 0 !== \strpos( $real_path, \realpath( $theme_dir ) ) ) {
continue;
}
return $real_path;
}
}
return false;
}
/**
* Find a TTF/OTF file from the WordPress Font Library.
*
* @return string|false Path to TTF file or false.
*/
private static function find_ttf_in_font_library() {
$font_families = \get_posts(
array(
'post_type' => 'wp_font_family',
'posts_per_page' => 10,
'post_status' => 'publish',
)
);
foreach ( $font_families as $font_family ) {
$faces = \get_posts(
array(
'post_type' => 'wp_font_face',
'post_parent' => $font_family->ID,
'posts_per_page' => 10,
'post_status' => 'publish',
)
);
foreach ( $faces as $face ) {
$file = \get_post_meta( $face->ID, '_wp_font_face_file', true );
if ( $file && \preg_match( '/\.(ttf|otf)$/i', $file ) ) {
$path = \path_join( \wp_get_font_dir()['path'], $file );
if ( \file_exists( $path ) ) {
return $path;
}
}
}
}
return false;
}
}