$block_id,
- 'class' => 'activitypub-stats',
- )
- );
+ echo $wrapper_html;
?>
+ data-year=""
>
-
data-year="">
-
-
-
-
-
- $type_info ) : ?>
-
- 0 ) : ?>
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ $type_info ) : ?>
+
+ 0 ) : ?>
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
; ?>)
-
diff --git a/src/stats/style.scss b/src/stats/style.scss
index bfe7cfa2f8..72d47d42d4 100644
--- a/src/stats/style.scss
+++ b/src/stats/style.scss
@@ -1,6 +1,11 @@
.wp-block-activitypub-stats {
max-width: var(--wp--style--global--content-size, 600px);
- margin: 2rem auto;
+ background-color: var(--wp--preset--color--base, var(--wp--preset--color--white, #fff));
+ color: var(--wp--preset--color--contrast, var(--wp--preset--color--black, inherit));
+
+ // Inner border color: uses block border-color if set, otherwise muted text color.
+ --activitypub-stats--border-color: color-mix(in srgb, currentcolor 20%, transparent);
+
&.alignwide {
max-width: var(--wp--style--global--wide-size);
@@ -11,14 +16,6 @@
}
}
-.activitypub-stats__card {
- color: inherit;
- border: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
- border-radius: 12px;
- padding: 2rem;
-
-}
-
.activitypub-stats__header {
text-align: center;
margin-bottom: 1.5rem;
@@ -51,7 +48,7 @@
}
.activitypub-stats__stat--highlight {
- border: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
+ border: 1px solid var(--activitypub-stats--border-color);
border-radius: 8px;
}
@@ -86,7 +83,7 @@
.activitypub-stats__engagement .activitypub-stats__stat {
flex: 1 1 calc(33.333% - 0.5rem);
min-width: 5rem;
- border: 1px solid color-mix(in srgb, currentcolor 15%, transparent);
+ border: 1px solid var(--activitypub-stats--border-color);
border-radius: 8px;
padding: 0.625rem 0.5rem;
}
@@ -101,7 +98,7 @@
.activitypub-stats__detail {
flex: 1 1 calc(50% - 0.5rem);
min-width: 7.5rem;
- border: 1px solid color-mix(in srgb, currentcolor 15%, transparent);
+ border: 1px solid var(--activitypub-stats--border-color);
border-radius: 8px;
padding: 0.875rem;
}
@@ -165,10 +162,9 @@
/* stylelint-disable no-descending-specificity */
.activitypub-stats__top-posts li {
padding: 0.5rem 0;
- border-bottom: 1px solid color-mix(in srgb, currentcolor 10%, transparent);
&:last-child {
- border-bottom: none;
+ padding-bottom: 0;
}
a {
@@ -197,9 +193,3 @@
color: color-mix(in srgb, currentcolor 45%, transparent);
}
-.activitypub-stats__image {
- display: block;
- width: 100%;
- height: auto;
- border-radius: 12px;
-}
From 00054d9b291c9f4a283ffbf3e985fcf44c9b0ea4 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:06:46 +0200
Subject: [PATCH 04/32] Add tests and fix PHPCS issues for stats block
- Add REST controller tests for the stats image endpoint (route
registration, 404 on missing stats, public access, color params).
- Add block tests for image attachment (with/without stats block,
preserving existing attachments, user ID handling, URL generation
with plain and pretty permalinks).
- Replace custom strip_stats_block method with __return_empty_string.
- Fix PHPCS issues: missing param docs, formatting, unused params.
---
includes/class-blocks.php | 21 +-
.../rest/class-stats-image-controller.php | 27 +--
src/stats/render.php | 4 +-
.../tests/includes/class-test-blocks.php | 114 +++++++++++
.../class-test-stats-image-controller.php | 189 ++++++++++++++++++
5 files changed, 321 insertions(+), 34 deletions(-)
create mode 100644 tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 8ce9df304f..469cf9d746 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -954,7 +954,7 @@ public static function add_directions( $content, $selector, $attributes ) {
*/
public static function add_post_transformation_callbacks( $post ) {
\add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
- \add_filter( 'render_block_activitypub/stats', array( self::class, 'strip_stats_block' ), 10, 2 );
+ \add_filter( 'render_block_activitypub/stats', '__return_empty_string' );
// Only transform reply link if it's the first block in the post.
$blocks = \parse_blocks( $post->post_content );
@@ -973,7 +973,7 @@ public static function add_post_transformation_callbacks( $post ) {
public static function remove_post_transformation_callbacks( $content ) {
\remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
\remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
- \remove_filter( 'render_block_activitypub/stats', array( self::class, 'strip_stats_block' ) );
+ \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
return $content;
}
@@ -1076,23 +1076,6 @@ public static function add_stats_image_attachment( $attachments, $post ) {
return $attachments;
}
- /**
- * Strip the stats block from federated content.
- *
- * The stats image is attached separately as an ActivityPub attachment,
- * so the block HTML should not appear in the content.
- *
- * @since unreleased
- *
- * @param string $block_content The block content.
- * @param array $block The block data.
- *
- * @return string Empty string.
- */
- public static function strip_stats_block( $block_content, $block ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
- return '';
- }
-
/**
* Get the stats image URL for a given user and year.
*
diff --git a/includes/rest/class-stats-image-controller.php b/includes/rest/class-stats-image-controller.php
index a2f24ce1ce..04a2252614 100644
--- a/includes/rest/class-stats-image-controller.php
+++ b/includes/rest/class-stats-image-controller.php
@@ -277,6 +277,8 @@ private function render_image( $summary, $actor_webfinger, $site_name, $year, $c
* Uses the theme's base/contrast palette colors for background and
* foreground text. Derives a muted color by blending toward the background.
*
+ * @param array $overrides Optional bg/fg hex color overrides (without #).
+ *
* @return array Associative array with 'bg', 'fg', and 'muted' keys,
* each containing an array of [r, g, b] values.
*/
@@ -326,7 +328,7 @@ private function resolve_colors( $overrides = array() ) {
}
// Try to resolve background color from Global Styles.
- $styles = \wp_get_global_styles( array( 'color' ) );
+ $styles = \wp_get_global_styles( array( 'color' ) );
$bg_resolved = $this->resolve_style_color( $styles['background'] ?? '', $palette );
$fg_resolved = $this->resolve_style_color( $styles['text'] ?? '', $palette );
@@ -555,11 +557,11 @@ function ( $a, $b ) use ( $body_slug ) {
* 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 $y The y position (baseline for TTF, top for built-in).
- * @param int|float $size Font size in points (TTF) or 1-5 (built-in).
- * @param int $color The text color.
+ * @param resource $image The image resource.
+ * @param string $text The text to draw.
+ * @param int $y The y position (baseline for TTF, top for built-in).
+ * @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 function draw_text_centered( $image, $text, $y, $size, $color, $font = false ) {
@@ -580,12 +582,12 @@ private function draw_text_centered( $image, $text, $y, $size, $color, $font = f
/**
* Draw text centered at a specific x position.
*
- * @param resource $image The image resource.
- * @param string $text The text to draw.
- * @param int $x The center x position.
- * @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 resource $image The image resource.
+ * @param string $text The text to draw.
+ * @param int $x The center x position.
+ * @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 function draw_text_at( $image, $text, $x, $y, $size, $color, $font = false ) {
@@ -600,5 +602,4 @@ private function draw_text_at( $image, $text, $x, $y, $size, $color, $font = fal
\imagestring( $image, $builtin_size, (int) ( $x - $text_width / 2 ), $y, $text, $color );
}
}
-
}
diff --git a/src/stats/render.php b/src/stats/render.php
index c33e358e89..8db0e3c70b 100644
--- a/src/stats/render.php
+++ b/src/stats/render.php
@@ -128,8 +128,8 @@
'class' => 'activitypub-stats',
);
-$extra_styles = ! empty( $border_styles ) ? \implode( ';', $border_styles ) : '';
-$wrapper_html = \get_block_wrapper_attributes( $wrapper_attrs );
+$extra_styles = ! empty( $border_styles ) ? \implode( ';', $border_styles ) : '';
+$wrapper_html = \get_block_wrapper_attributes( $wrapper_attrs );
// Merge our border styles into the existing style attribute.
if ( $extra_styles ) {
diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php
index 7a3c0afb24..f9e950dcae 100644
--- a/tests/phpunit/tests/includes/class-test-blocks.php
+++ b/tests/phpunit/tests/includes/class-test-blocks.php
@@ -1027,4 +1027,118 @@ public function test_render_extra_fields_block_preserves_html() {
$this->assertStringContainsString( 'my site', $output );
$this->assertStringContainsString( 'post->create_and_get(
+ array(
+ 'post_content' => '',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $attachments = Blocks::add_stats_image_attachment( array(), $post );
+
+ $this->assertCount( 1, $attachments );
+ $this->assertSame( 'Image', $attachments[0]['type'] );
+ $this->assertSame( 'image/png', $attachments[0]['mediaType'] );
+ $this->assertStringContainsString( 'stats/image/0/2025', $attachments[0]['url'] );
+ $this->assertStringContainsString( '2025', $attachments[0]['name'] );
+ }
+
+ /**
+ * Test add_stats_image_attachment with no stats block.
+ *
+ * @covers ::add_stats_image_attachment
+ */
+ public function test_add_stats_image_attachment_no_block() {
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_content' => 'Hello world
',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $attachments = Blocks::add_stats_image_attachment( array(), $post );
+
+ $this->assertCount( 0, $attachments );
+ }
+
+ /**
+ * Test add_stats_image_attachment preserves existing attachments.
+ *
+ * @covers ::add_stats_image_attachment
+ */
+ public function test_add_stats_image_attachment_preserves_existing() {
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_content' => '',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $existing = array(
+ array(
+ 'type' => 'Image',
+ 'url' => 'https://example.com/photo.jpg',
+ ),
+ );
+
+ $attachments = Blocks::add_stats_image_attachment( $existing, $post );
+
+ $this->assertCount( 2, $attachments );
+ $this->assertSame( 'https://example.com/photo.jpg', $attachments[0]['url'] );
+ $this->assertStringContainsString( 'stats/image', $attachments[1]['url'] );
+ }
+
+ /**
+ * Test add_stats_image_attachment with user ID.
+ *
+ * @covers ::add_stats_image_attachment
+ */
+ public function test_add_stats_image_attachment_with_user_id() {
+ $post = self::factory()->post->create_and_get(
+ array(
+ 'post_content' => '',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $attachments = Blocks::add_stats_image_attachment( array(), $post );
+
+ $this->assertCount( 1, $attachments );
+ $this->assertStringContainsString( 'stats/image/1/2024', $attachments[0]['url'] );
+ }
+
+ /**
+ * Test get_stats_image_url generates valid URL.
+ *
+ * @covers ::get_stats_image_url
+ */
+ public function test_get_stats_image_url() {
+ $url = Blocks::get_stats_image_url( 0, 2025 );
+
+ $this->assertStringContainsString( 'stats/image/0/2025', $url );
+ }
+
+ /**
+ * Test get_stats_image_url works with plain permalinks.
+ *
+ * @covers ::get_stats_image_url
+ */
+ public function test_get_stats_image_url_plain_permalinks() {
+ \update_option( 'permalink_structure', '' );
+
+ $url = Blocks::get_stats_image_url( 1, 2024 );
+
+ $this->assertStringContainsString( 'stats/image/1/2024', $url );
+ $this->assertStringContainsString( 'rest_route', $url );
+
+ // Restore.
+ \update_option( 'permalink_structure', '/%postname%/' );
+ }
}
diff --git a/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php b/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
new file mode 100644
index 0000000000..d534882011
--- /dev/null
+++ b/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
@@ -0,0 +1,189 @@
+user->create( array( 'role' => 'author' ) );
+ \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' );
+ }
+
+ /**
+ * Seed demo stats for a user.
+ *
+ * @param int $user_id The user ID.
+ * @param int $year The year.
+ */
+ private function seed_stats( $user_id, $year ) {
+ $option_name = \sprintf( 'activitypub_stats_%d_%d_annual', $user_id, $year );
+
+ \update_option(
+ $option_name,
+ array(
+ 'posts_count' => 42,
+ 'most_active_month' => 6,
+ 'followers_start' => 100,
+ 'followers_end' => 250,
+ 'followers_net_change' => 150,
+ 'top_multiplicator' => array(
+ 'name' => '@supporter@example.com',
+ 'url' => 'https://example.com/@supporter',
+ 'count' => 12,
+ ),
+ 'top_posts' => array(
+ array(
+ 'title' => 'Test Post',
+ 'url' => 'https://example.com/test-post/',
+ 'engagement_count' => 50,
+ ),
+ ),
+ 'like_count' => 100,
+ 'repost_count' => 50,
+ 'comment_count' => 25,
+ 'quote_count' => 10,
+ 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ),
+ ),
+ false
+ );
+ }
+
+ /**
+ * Test route registration.
+ *
+ * @covers ::register_routes
+ */
+ public function test_register_routes() {
+ $routes = \rest_get_server()->get_routes();
+ $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/(?P[\\d]+)/(?P[\\d]{4})', $routes );
+ }
+
+ /**
+ * Test getting a stats image returns valid response.
+ *
+ * Note: The controller calls exit() after outputting the PNG, so we
+ * cannot fully dispatch the request in tests. Instead we verify the
+ * route exists and test error cases that return before the exit.
+ *
+ * @covers ::get_item
+ */
+ public function test_get_item() {
+ $this->seed_stats( self::$user_id, 2025 );
+
+ // Verify the route exists and accepts valid parameters.
+ $routes = \rest_get_server()->get_routes();
+ $route = '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/(?P[\\d]+)/(?P[\\d]{4})';
+ $this->assertArrayHasKey( $route, $routes );
+
+ // Verify a GET endpoint is registered.
+ $endpoints = $routes[ $route ];
+ $methods = array();
+ foreach ( $endpoints as $endpoint ) {
+ if ( isset( $endpoint['methods'] ) ) {
+ $methods = \array_merge( $methods, \array_keys( $endpoint['methods'] ) );
+ }
+ }
+ $this->assertContains( 'GET', $methods );
+ }
+
+ /**
+ * Test schema (OPTIONS request).
+ *
+ * @covers ::register_routes
+ */
+ public function test_get_item_schema() {
+ $request = new \WP_REST_Request( 'OPTIONS', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/0/2025' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'endpoints', $data );
+ }
+
+ /**
+ * Test 404 when no stats exist.
+ *
+ * @covers ::get_item
+ */
+ public function test_get_item_no_stats() {
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . self::$user_id . '/1999' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'no_stats', $data['code'] );
+ }
+
+ /**
+ * Test endpoint is publicly accessible (no auth error on missing stats).
+ *
+ * @covers ::register_routes
+ */
+ public function test_endpoint_is_public() {
+ \wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . self::$user_id . '/1999' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ // Should get 404 (no stats), not 401/403.
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test route accepts color parameters.
+ *
+ * @covers ::register_routes
+ */
+ public function test_route_accepts_color_params() {
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . self::$user_id . '/1999' );
+ $request->set_param( 'bg', 'ff0000' );
+ $request->set_param( 'fg', '00ff00' );
+
+ $response = \rest_get_server()->dispatch( $request );
+
+ // Should get 404 (no stats), not 400 (bad params).
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test invalid year format.
+ *
+ * @covers ::register_routes
+ */
+ public function test_invalid_year_format() {
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . self::$user_id . '/99' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ // Route pattern requires 4 digits, so this should 404 (no route match).
+ $this->assertEquals( 404, $response->get_status() );
+ }
+}
From 14a9fb5f8e70311c478a39bcfcc835b6642a6a94 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:08:53 +0200
Subject: [PATCH 05/32] Remove demo stats seeder, use CLI commands instead
---
bin/seed-demo-stats.php | 83 -----------------------------------------
1 file changed, 83 deletions(-)
delete mode 100644 bin/seed-demo-stats.php
diff --git a/bin/seed-demo-stats.php b/bin/seed-demo-stats.php
deleted file mode 100644
index 76c75f970e..0000000000
--- a/bin/seed-demo-stats.php
+++ /dev/null
@@ -1,83 +0,0 @@
- 142,
- 'most_active_month' => 3,
- 'followers_start' => 487,
- 'followers_end' => 1203,
- 'followers_net_change' => 716,
- 'top_multiplicator' => array(
- 'name' => '@evan@cosocial.ca',
- 'url' => 'https://cosocial.ca/@evan',
- 'count' => 38,
- ),
- 'top_posts' => array(
- array(
- 'title' => 'Why ActivityPub is the Future of Social Networking',
- 'url' => home_url( '/2025/03/activitypub-future/' ),
- 'engagement_count' => 234,
- ),
- array(
- 'title' => 'Introducing Fediverse Stats for WordPress',
- 'url' => home_url( '/2025/06/fediverse-stats/' ),
- 'engagement_count' => 189,
- ),
- array(
- 'title' => 'How to Set Up Your Blog for Federation',
- 'url' => home_url( '/2025/01/federation-setup/' ),
- 'engagement_count' => 156,
- ),
- array(
- 'title' => 'The IndieWeb and the Fediverse: Better Together',
- 'url' => home_url( '/2025/09/indieweb-fediverse/' ),
- 'engagement_count' => 98,
- ),
- array(
- 'title' => 'Year in Review: Open Standards Won',
- 'url' => home_url( '/2025/12/year-in-review/' ),
- 'engagement_count' => 87,
- ),
- ),
- 'like_count' => 1847,
- 'announce_count' => 623,
- 'comment_count' => 312,
- 'compiled_at' => gmdate( 'Y-m-d H:i:s' ),
-);
-
-update_option( $option_name, $demo_summary, false );
-
-WP_CLI::success(
- sprintf(
- 'Seeded demo stats for user %d, year %d (option: %s).',
- $user_id,
- $year,
- $option_name
- )
-);
From db0c41bf9b98ffa730c32d4c3b185d50419a927e Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:37:10 +0200
Subject: [PATCH 06/32] Extract image generation into Cache\Stats_Image class
- Move image generation, caching, color/font resolution from the REST
controller into a dedicated Cache\Stats_Image class.
- Controller is now a thin layer with two endpoints:
/stats/image/{user_id}/{year} serves the image binary,
/stats/image-url/{user_id}/{year} returns the resolved URL as JSON.
- Cache stores images in uploads/activitypub/stats/{user_id}/ using
WP_Image_Editor for WebP optimization.
- Add activitypub_stats_image_url filter for CDN/Photon integration.
- Add activitypub_cache_stats_image_enabled filter to disable caching.
- Editor sidebar fetches the resolved URL via the image-url endpoint.
- ActivityPub attachment uses the direct cached file URL.
---
includes/cache/class-stats-image.php | 710 ++++++++++++++++++
includes/class-blocks.php | 38 +-
.../rest/class-stats-image-controller.php | 601 ++-------------
src/stats/edit.js | 31 +-
4 files changed, 834 insertions(+), 546 deletions(-)
create mode 100644 includes/cache/class-stats-image.php
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
new file mode 100644
index 0000000000..7ab27d7321
--- /dev/null
+++ b/includes/cache/class-stats-image.php
@@ -0,0 +1,710 @@
+ 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 \Activitypub\Model\Blog();
+ } elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
+ $actor = new \Activitypub\Model\Application();
+ }
+ }
+
+ $actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
+ $site_name = \get_bloginfo( 'name' );
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+
+ $tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year, $color_overrides );
+
+ if ( \is_wp_error( $tmp_file ) ) {
+ return $tmp_file;
+ }
+
+ $cache_key = self::get_cache_key( $user_id, $year, $color_overrides );
+ $result = self::optimize_and_store( $tmp_file, $cache_key );
+
+ \wp_delete_file( $tmp_file );
+
+ return $result;
+ }
+
+ /**
+ * Build a cache key from the image parameters.
+ *
+ * @param int $user_id The user ID.
+ * @param int $year The year.
+ * @param array $color_overrides The color overrides.
+ *
+ * @return array Cache key with dir, base, hash.
+ */
+ private static function get_cache_key( $user_id, $year, $color_overrides ) {
+ $upload_dir = \wp_upload_dir();
+ $hash = \md5( \wp_json_encode( \array_filter( $color_overrides ) ) );
+
+ return array(
+ 'dir' => $upload_dir['basedir'] . self::BASE_DIR . $user_id,
+ 'base' => \sprintf( 'stats-%d-%s', $year, $hash ),
+ );
+ }
+
+ /**
+ * Look for a cached image.
+ *
+ * @param array $cache_key The cache key.
+ *
+ * @return array|false { path, mime_type } or false if not cached.
+ */
+ private static function get_cached( $cache_key ) {
+ $extensions = array(
+ 'webp' => 'image/webp',
+ 'png' => 'image/png',
+ );
+
+ foreach ( $extensions as $ext => $mime ) {
+ $path = $cache_key['dir'] . '/' . $cache_key['base'] . '.' . $ext;
+ if ( \file_exists( $path ) ) {
+ return array(
+ 'path' => $path,
+ 'mime_type' => $mime,
+ );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Optimize the image via WP_Image_Editor and save to cache.
+ *
+ * @param string $tmp_file Path to the temporary PNG.
+ * @param array $cache_key The cache key.
+ *
+ * @return array|\WP_Error { path, mime_type } or error.
+ */
+ private static function optimize_and_store( $tmp_file, $cache_key ) {
+ if ( ! \wp_mkdir_p( $cache_key['dir'] ) ) {
+ return new \WP_Error(
+ 'cache_dir_failed',
+ \__( 'Failed to create cache directory.', 'activitypub' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $editor = \wp_get_image_editor( $tmp_file );
+ $mime_type = 'image/png';
+ $ext = 'png';
+
+ if ( ! \is_wp_error( $editor ) && $editor->supports_mime_type( 'image/webp' ) ) {
+ $mime_type = 'image/webp';
+ $ext = 'webp';
+ }
+
+ $dest_path = $cache_key['dir'] . '/' . $cache_key['base'] . '.' . $ext;
+
+ if ( ! \is_wp_error( $editor ) ) {
+ $result = $editor->save( $dest_path, $mime_type );
+
+ if ( ! \is_wp_error( $result ) ) {
+ return array(
+ 'path' => $result['path'],
+ 'mime_type' => $mime_type,
+ );
+ }
+ }
+
+ // Fallback: copy the PNG directly.
+ \copy( $tmp_file, $dest_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy
+ return array(
+ 'path' => $dest_path,
+ 'mime_type' => 'image/png',
+ );
+ }
+
+ /**
+ * Convert a filesystem path to a public URL.
+ *
+ * @param string $path The filesystem path.
+ *
+ * @return string The public URL.
+ */
+ private static function path_to_url( $path ) {
+ $upload_dir = \wp_upload_dir();
+ return \str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $path );
+ }
+
+ /**
+ * 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.
+ * @param array $color_overrides Optional bg/fg hex color overrides.
+ *
+ * @return string|\WP_Error Path to temporary PNG file or error.
+ */
+ private static function render( $summary, $actor_webfinger, $site_name, $year, $color_overrides = array() ) {
+ $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( $color_overrides );
+ $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;
+ }
+
+ $followers_end = $summary['followers_end'] ?? 0;
+
+ // Title.
+ $title = \sprintf(
+ /* translators: %d: The year */
+ \__( 'Fediverse Stats %d', 'activitypub' ),
+ $year
+ );
+ self::draw_text_centered( $image, $title, 100, 36, $fg, $font );
+
+ // Actor webfinger.
+ if ( $actor_webfinger ) {
+ self::draw_text_centered( $image, $actor_webfinger, 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( $followers_end ),
+ '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_at( $image, $stat['value'], $center_x, 300, 56, $fg, $font );
+ self::draw_text_at( $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_centered( $image, $growth_text, 450, 20, $muted, $font );
+
+ // Branding.
+ $branding = $site_name . ' - ' . \__( 'Powered by ActivityPub', 'activitypub' );
+ self::draw_text_centered( $image, $branding, $height - 40, 14, $muted, $font );
+
+ // Save to temp file.
+ $tmp_file = \wp_tempnam( 'activitypub-stats-' );
+ \imagepng( $image, $tmp_file );
+ \imagedestroy( $image );
+
+ return $tmp_file;
+ }
+
+ /**
+ * Resolve colors from theme Global Styles or overrides.
+ *
+ * @param array $overrides Optional bg/fg hex color overrides.
+ *
+ * @return array Associative array with 'bg', 'fg', and 'muted' RGB arrays.
+ */
+ private static function resolve_colors( $overrides = array() ) {
+ $bg_rgb = array( 255, 255, 255 );
+ $fg_rgb = array( 17, 17, 17 );
+
+ if ( ! empty( $overrides['bg'] ) ) {
+ $parsed = self::parse_hex( $overrides['bg'] );
+ if ( $parsed ) {
+ $bg_rgb = $parsed;
+ }
+ }
+
+ if ( ! empty( $overrides['fg'] ) ) {
+ $parsed = self::parse_hex( $overrides['fg'] );
+ if ( $parsed ) {
+ $fg_rgb = $parsed;
+ }
+ }
+
+ if ( ! empty( $overrides['bg'] ) && ! empty( $overrides['fg'] ) ) {
+ return self::build_color_set( $bg_rgb, $fg_rgb );
+ }
+
+ $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 ) ) {
+ $slug = $matches[1];
+ if ( ! empty( $palette[ $slug ] ) ) {
+ return self::parse_hex( $palette[ $slug ] );
+ }
+ }
+
+ 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;
+ }
+
+ return array(
+ \hexdec( \substr( $hex, 0, 2 ) ),
+ \hexdec( \substr( $hex, 2, 2 ) ),
+ \hexdec( \substr( $hex, 4, 2 ) ),
+ );
+ }
+
+ /**
+ * 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'] ) ) {
+ if ( \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;
+ }
+ }
+
+ if ( $body_slug ) {
+ \usort(
+ $all_families,
+ function ( $a, $b ) use ( $body_slug ) {
+ $a_match = ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1;
+ $b_match = ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1;
+ return $a_match - $b_match;
+ }
+ );
+ }
+
+ foreach ( $all_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;
+ }
+
+ if ( 0 === \strpos( $src, 'file:./' ) ) {
+ $src = \get_theme_file_path( \substr( $src, 7 ) );
+ }
+
+ if ( \file_exists( $src ) ) {
+ return $src;
+ }
+ }
+ }
+ }
+
+ // Try the Font Library (WP 6.5+).
+ $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;
+ }
+ }
+ }
+ }
+
+ $fallbacks = array(
+ ABSPATH . 'wp-content/themes/twentytwentytwo/assets/fonts/dm-sans/DMSans-Regular.ttf',
+ ABSPATH . 'wp-content/themes/twentytwentythree/assets/fonts/dm-sans/DMSans-Regular.ttf',
+ );
+
+ foreach ( $fallbacks as $path ) {
+ if ( \file_exists( $path ) ) {
+ return $path;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Draw centered text on the image.
+ *
+ * @param resource $image The image resource.
+ * @param string $text The text to draw.
+ * @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_centered( $image, $text, $y, $size, $color, $font = false ) {
+ if ( $font && \function_exists( 'imagefttext' ) ) {
+ $bbox = \imageftbbox( $size, 0, $font, $text );
+ $text_width = $bbox[2] - $bbox[0];
+ $x = (int) ( ( self::WIDTH - $text_width ) / 2 );
+ \imagefttext( $image, $size, 0, $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 );
+ $x = (int) ( ( self::WIDTH - $text_width ) / 2 );
+ \imagestring( $image, $builtin_size, $x, $y, $text, $color );
+ }
+ }
+
+ /**
+ * Draw text centered at a specific x position.
+ *
+ * @param resource $image The image resource.
+ * @param string $text The text to draw.
+ * @param int $x The center x position.
+ * @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_at( $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];
+ \imagefttext( $image, $size, 0, (int) ( $x - $text_width / 2 ), $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 );
+ \imagestring( $image, $builtin_size, (int) ( $x - $text_width / 2 ), $y, $text, $color );
+ }
+ }
+}
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 469cf9d746..46583f5418 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -7,6 +7,7 @@
namespace Activitypub;
+use Activitypub\Cache\Stats_Image;
use Activitypub\Collection\Actors;
/**
@@ -88,21 +89,21 @@ public static function init() {
*/
public static function enqueue_editor_assets() {
$data = array(
- 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
- 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
- 'enabled' => array(
+ 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
+ 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
+ 'enabled' => array(
'blog' => ! is_user_type_disabled( 'blog' ),
'users' => ! is_user_type_disabled( 'user' ),
),
- 'profileUrls' => array(
+ 'profileUrls' => array(
'user' => \admin_url( 'profile.php#activitypub' ),
'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
),
- 'showAvatars' => (bool) \get_option( 'show_avatars' ),
- 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
- 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
- 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
- 'statsImageUrl' => \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/{user_id}/{year}' ),
+ 'showAvatars' => (bool) \get_option( 'show_avatars' ),
+ 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
+ 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
+ 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
+ 'statsImageUrlEndpoint' => \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/{user_id}/{year}' ),
);
wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
@@ -1060,11 +1061,15 @@ public static function add_stats_image_attachment( $attachments, $post ) {
$user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
$year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
+ $url = Stats_Image::get_url( $user_id, $year );
+
+ // Determine mime type from URL extension.
+ $mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
$attachments[] = array(
'type' => 'Image',
- 'mediaType' => 'image/png',
- 'url' => self::get_stats_image_url( $user_id, $year ),
+ 'mediaType' => $mime_type,
+ 'url' => $url,
'name' => \sprintf(
/* translators: %d: The year */
\__( 'Fediverse Stats %d', 'activitypub' ),
@@ -1079,7 +1084,8 @@ public static function add_stats_image_attachment( $attachments, $post ) {
/**
* Get the stats image URL for a given user and year.
*
- * Uses get_rest_url() which works with both pretty and plain permalinks.
+ * Returns the direct cached file URL if available, otherwise
+ * falls back to the REST endpoint URL.
*
* @since unreleased
*
@@ -1089,7 +1095,13 @@ public static function add_stats_image_attachment( $attachments, $post ) {
* @return string The image URL.
*/
public static function get_stats_image_url( $user_id, $year ) {
- return \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
+ $url = Stats_Image::get_url( $user_id, $year );
+
+ if ( \is_wp_error( $url ) ) {
+ return \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
+ }
+
+ return $url;
}
/**
diff --git a/includes/rest/class-stats-image-controller.php b/includes/rest/class-stats-image-controller.php
index 04a2252614..c21352036c 100644
--- a/includes/rest/class-stats-image-controller.php
+++ b/includes/rest/class-stats-image-controller.php
@@ -2,22 +2,20 @@
/**
* Stats_Image_Controller file.
*
- * Generates a shareable PNG image of ActivityPub statistics.
- *
* @package Activitypub
* @since unreleased
*/
namespace Activitypub\Rest;
-use Activitypub\Collection\Actors;
-use Activitypub\Statistics;
+use Activitypub\Cache\Stats_Image;
/**
- * REST controller that renders stats as a PNG image.
+ * REST controller that serves stats share images.
*
- * Endpoint: /activitypub/v1/stats/image//
- * Returns a 1200×630 PNG suitable for Open Graph / social media cards.
+ * Provides two endpoints:
+ * - /stats/image/{user_id}/{year} — serves the image binary
+ * - /stats/image-url/{user_id}/{year} — returns the image URL as JSON
*/
class Stats_Image_Controller extends \WP_REST_Controller {
@@ -36,570 +34,123 @@ class Stats_Image_Controller extends \WP_REST_Controller {
protected $rest_base = 'stats/image';
/**
- * Image width in pixels.
- *
- * @var int
- */
- const WIDTH = 1200;
-
- /**
- * Image height in pixels.
+ * Common route args for user_id and year.
*
- * @var int
+ * @return array The route args.
*/
- const HEIGHT = 630;
+ private function get_common_args() {
+ return array(
+ 'user_id' => array(
+ 'description' => \__( 'The user ID to generate the stats image for.', 'activitypub' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'year' => array(
+ 'description' => \__( 'The year to display stats for.', 'activitypub' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'bg' => array(
+ 'description' => \__( 'Background color as hex (without #).', 'activitypub' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_hex_color_no_hash',
+ ),
+ 'fg' => array(
+ 'description' => \__( 'Text color as hex (without #).', 'activitypub' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_hex_color_no_hash',
+ ),
+ );
+ }
/**
* Register routes.
*/
public function register_routes() {
+ $route_pattern = '/(?P[\d]+)/(?P[\d]{4})';
+
+ // Serve the image binary.
\register_rest_route(
$this->namespace,
- '/' . $this->rest_base . '/(?P[\d]+)/(?P[\d]{4})',
+ '/' . $this->rest_base . $route_pattern,
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
- 'args' => array(
- 'user_id' => array(
- 'description' => \__( 'The user ID to generate the stats image for.', 'activitypub' ),
- 'type' => 'integer',
- 'required' => true,
- 'sanitize_callback' => 'absint',
- ),
- 'year' => array(
- 'description' => \__( 'The year to display stats for.', 'activitypub' ),
- 'type' => 'integer',
- 'required' => true,
- 'sanitize_callback' => 'absint',
- ),
- 'bg' => array(
- 'description' => \__( 'Background color as hex (without #).', 'activitypub' ),
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_hex_color_no_hash',
- ),
- 'fg' => array(
- 'description' => \__( 'Text color as hex (without #).', 'activitypub' ),
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_hex_color_no_hash',
- ),
- ),
+ 'args' => $this->get_common_args(),
),
)
);
- }
-
- /**
- * Generate and return the stats image.
- *
- * @param \WP_REST_Request $request The request object.
- *
- * @return \WP_REST_Response|\WP_Error Response with PNG data or error.
- */
- public function get_item( $request ) {
- if ( ! \function_exists( 'imagecreatetruecolor' ) ) {
- return new \WP_Error(
- 'gd_not_available',
- \__( 'GD library is not available.', 'activitypub' ),
- array( 'status' => 501 )
- );
- }
-
- $user_id = (int) $request->get_param( 'user_id' );
- $year = (int) $request->get_param( 'year' );
-
- $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 \Activitypub\Model\Blog();
- } elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
- $actor = new \Activitypub\Model\Application();
- }
- }
-
- $actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
- $site_name = \get_bloginfo( 'name' );
-
- $color_overrides = array(
- 'bg' => $request->get_param( 'bg' ),
- 'fg' => $request->get_param( 'fg' ),
- );
-
- $png_data = $this->render_image( $summary, $actor_webfinger, $site_name, $year, $color_overrides );
- if ( \is_wp_error( $png_data ) ) {
- return $png_data;
- }
-
- $response = new \WP_REST_Response( null, 200 );
- $response->set_headers(
+ // Return the image URL as JSON.
+ \register_rest_route(
+ $this->namespace,
+ '/stats/image-url' . $route_pattern,
array(
- 'Content-Type' => 'image/png',
- 'Content-Length' => strlen( $png_data ),
- 'Cache-Control' => 'public, max-age=86400',
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_url' ),
+ 'permission_callback' => '__return_true',
+ 'args' => $this->get_common_args(),
+ ),
)
);
-
- // Output the image directly and exit, since WP REST API can't stream binary.
- header( 'Content-Type: image/png' );
- header( 'Content-Length: ' . strlen( $png_data ) );
- header( 'Cache-Control: public, max-age=86400' );
- echo $png_data; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- exit;
}
/**
- * Render the stats image as PNG.
+ * Serve the stats image binary.
*
- * @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.
- * @param array $color_overrides Optional bg/fg hex color overrides (without #).
+ * @param \WP_REST_Request $request The request object.
*
- * @return string|\WP_Error PNG binary data or error.
+ * @return void|\WP_Error Streams image and exits, or returns error.
*/
- private function render_image( $summary, $actor_webfinger, $site_name, $year, $color_overrides = array() ) {
- $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 );
-
- // Resolve colors: query params override theme detection.
- $colors = $this->resolve_colors( $color_overrides );
- $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 );
-
- // Resolve a TTF font from the active theme or fall back.
- $font = $this->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;
- }
-
- $followers_end = $summary['followers_end'] ?? 0;
-
- // Title.
- $title = \sprintf(
- /* translators: %d: The year */
- \__( 'Fediverse Stats %d', 'activitypub' ),
- $year
- );
- $this->draw_text_centered( $image, $title, 100, 36, $fg, $font );
-
- // Actor name.
- if ( $actor_webfinger ) {
- $this->draw_text_centered( $image, $actor_webfinger, 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( $followers_end ),
- 'label' => \__( 'Followers', 'activitypub' ),
- ),
- );
-
- $col_width = (int) ( $width / 3 );
-
- foreach ( $stats as $i => $stat ) {
- $center_x = (int) ( $col_width * $i + $col_width / 2 );
- $this->draw_text_at( $image, $stat['value'], $center_x, 300, 56, $fg, $font );
- $this->draw_text_at( $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 )
+ public function get_item( $request ) {
+ return Stats_Image::serve(
+ (int) $request->get_param( 'user_id' ),
+ (int) $request->get_param( 'year' ),
+ $this->get_color_overrides( $request )
);
- $this->draw_text_centered( $image, $growth_text, 450, 20, $muted, $font );
-
- // Branding.
- $branding = $site_name . ' - ' . \__( 'Powered by ActivityPub', 'activitypub' );
- $this->draw_text_centered( $image, $branding, $height - 40, 14, $muted, $font );
-
- // Output to buffer.
- \ob_start();
- \imagepng( $image );
- $data = \ob_get_clean();
- \imagedestroy( $image );
-
- return $data;
}
/**
- * Resolve colors from the active theme's Global Styles.
+ * Return the resolved image URL as JSON.
*
- * Uses the theme's base/contrast palette colors for background and
- * foreground text. Derives a muted color by blending toward the background.
+ * Returns the cached file URL if available, otherwise the REST
+ * endpoint URL. Filtered via `activitypub_stats_image_url` so
+ * it can be routed through a CDN or image proxy like Photon.
*
- * @param array $overrides Optional bg/fg hex color overrides (without #).
+ * @param \WP_REST_Request $request The request object.
*
- * @return array Associative array with 'bg', 'fg', and 'muted' keys,
- * each containing an array of [r, g, b] values.
+ * @return \WP_REST_Response|\WP_Error JSON response with the URL.
*/
- private function resolve_colors( $overrides = array() ) {
- $bg_rgb = array( 255, 255, 255 );
- $fg_rgb = array( 17, 17, 17 );
-
- // Apply query param overrides first.
- if ( ! empty( $overrides['bg'] ) ) {
- $parsed = $this->parse_hex( $overrides['bg'] );
- if ( $parsed ) {
- $bg_rgb = $parsed;
- }
- }
-
- if ( ! empty( $overrides['fg'] ) ) {
- $parsed = $this->parse_hex( $overrides['fg'] );
- if ( $parsed ) {
- $fg_rgb = $parsed;
- }
- }
-
- // If both overrides are set, skip theme detection.
- if ( ! empty( $overrides['bg'] ) && ! empty( $overrides['fg'] ) ) {
- $muted_rgb = 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 ),
- );
-
- return array(
- 'bg' => $bg_rgb,
- 'fg' => $fg_rgb,
- 'muted' => $muted_rgb,
- );
- }
-
- $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'];
- }
- }
- }
-
- // Try to resolve background color from Global Styles.
- $styles = \wp_get_global_styles( array( 'color' ) );
- $bg_resolved = $this->resolve_style_color( $styles['background'] ?? '', $palette );
- $fg_resolved = $this->resolve_style_color( $styles['text'] ?? '', $palette );
-
- if ( $bg_resolved ) {
- $bg_rgb = $bg_resolved;
- }
-
- if ( $fg_resolved ) {
- $fg_rgb = $fg_resolved;
- }
-
- // If styles didn't give us colors, try common palette slug conventions.
- if ( ! $bg_resolved || ! $fg_resolved ) {
- // Slug conventions across themes: base/contrast, background/foreground, white/black.
- $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 = $this->parse_hex( $palette[ $slug ] );
- if ( $parsed ) {
- $bg_rgb = $parsed;
- break;
- }
- }
- }
- }
-
- if ( ! $fg_resolved ) {
- foreach ( $fg_slugs as $slug ) {
- if ( ! empty( $palette[ $slug ] ) ) {
- $parsed = $this->parse_hex( $palette[ $slug ] );
- if ( $parsed ) {
- $fg_rgb = $parsed;
- break;
- }
- }
- }
- }
- }
-
- // Muted: blend foreground 50% toward background.
- $muted_rgb = 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 ),
- );
-
- return array(
- 'bg' => $bg_rgb,
- 'fg' => $fg_rgb,
- 'muted' => $muted_rgb,
+ public function get_url( $request ) {
+ $url = Stats_Image::get_url(
+ (int) $request->get_param( 'user_id' ),
+ (int) $request->get_param( 'year' ),
+ $this->get_color_overrides( $request )
);
- }
-
- /**
- * Resolve a color value from Global Styles.
- *
- * Handles hex colors directly and CSS variables referencing palette colors.
- *
- * @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 if unresolvable.
- */
- private function resolve_style_color( $value, $palette ) {
- if ( empty( $value ) ) {
- return false;
- }
-
- // Direct hex.
- if ( '#' === $value[0] ) {
- return $this->parse_hex( $value );
- }
- // CSS variable: var(--wp--preset--color--slug).
- if ( \preg_match( '/--color--([a-z0-9-]+)/', $value, $matches ) ) {
- $slug = $matches[1];
- if ( ! empty( $palette[ $slug ] ) ) {
- return $this->parse_hex( $palette[ $slug ] );
- }
+ if ( \is_wp_error( $url ) ) {
+ return $url;
}
- return false;
+ return \rest_ensure_response( array( 'url' => $url ) );
}
/**
- * Parse a hex color string into an RGB array.
+ * Extract color overrides from the request.
*
- * @param string $hex The hex color (e.g. '#FF0000' or '#F00').
+ * @param \WP_REST_Request $request The request object.
*
- * @return array|false Array of [r, g, b] or false on failure.
+ * @return array The color overrides.
*/
- private 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;
- }
-
+ private function get_color_overrides( $request ) {
return array(
- \hexdec( \substr( $hex, 0, 2 ) ),
- \hexdec( \substr( $hex, 2, 2 ) ),
- \hexdec( \substr( $hex, 4, 2 ) ),
- );
- }
-
- /**
- * Resolve a TTF font file from the active theme.
- *
- * Looks for TTF files referenced in the theme's font families via
- * Global Styles, then falls back to any TTF in the theme directory,
- * then to bundled WordPress theme fonts.
- *
- * @return string|false Path to a TTF file, or false if none found.
- */
- private function resolve_font() {
- // Determine which font family slug the body text uses.
- $body_slug = '';
- $styles = \wp_get_global_styles( array( 'typography' ) );
- if ( ! empty( $styles['fontFamily'] ) ) {
- // Extract slug from var(--wp--preset--font-family--slug).
- if ( \preg_match( '/--font-family--([a-z0-9-]+)/', $styles['fontFamily'], $matches ) ) {
- $body_slug = $matches[1];
- }
- }
-
- // Search theme font families for a TTF/OTF file.
- $settings = \wp_get_global_settings();
- if ( ! empty( $settings['typography']['fontFamilies'] ) ) {
- // If we know the body slug, try that family first.
- $all_families = array();
- foreach ( $settings['typography']['fontFamilies'] as $families ) {
- foreach ( $families as $family ) {
- $all_families[] = $family;
- }
- }
-
- // Sort: body font first.
- if ( $body_slug ) {
- \usort(
- $all_families,
- function ( $a, $b ) use ( $body_slug ) {
- $a_match = ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1;
- $b_match = ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1;
- return $a_match - $b_match;
- }
- );
- }
-
- foreach ( $all_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;
- }
-
- if ( 0 === \strpos( $src, 'file:./' ) ) {
- $src = \get_theme_file_path( \substr( $src, 7 ) );
- }
-
- if ( \file_exists( $src ) ) {
- return $src;
- }
- }
- }
- }
-
- // Try the Font Library (WP 6.5+).
- $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;
- }
- }
- }
- }
-
- // Fall back: common WordPress bundled theme fonts.
- $fallbacks = array(
- ABSPATH . 'wp-content/themes/twentytwentytwo/assets/fonts/dm-sans/DMSans-Regular.ttf',
- ABSPATH . 'wp-content/themes/twentytwentythree/assets/fonts/dm-sans/DMSans-Regular.ttf',
+ 'bg' => $request->get_param( 'bg' ),
+ 'fg' => $request->get_param( 'fg' ),
);
-
- foreach ( $fallbacks as $path ) {
- if ( \file_exists( $path ) ) {
- return $path;
- }
- }
-
- return false;
- }
-
- /**
- * Draw centered text on the image.
- *
- * 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 $y The y position (baseline for TTF, top for built-in).
- * @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 function draw_text_centered( $image, $text, $y, $size, $color, $font = false ) {
- if ( $font && \function_exists( 'imagefttext' ) ) {
- $bbox = \imageftbbox( $size, 0, $font, $text );
- $text_width = $bbox[2] - $bbox[0];
- $x = (int) ( ( self::WIDTH - $text_width ) / 2 );
- \imagefttext( $image, $size, 0, $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 );
- $x = (int) ( ( self::WIDTH - $text_width ) / 2 );
- \imagestring( $image, $builtin_size, $x, $y, $text, $color );
- }
- }
-
- /**
- * Draw text centered at a specific x position.
- *
- * @param resource $image The image resource.
- * @param string $text The text to draw.
- * @param int $x The center x position.
- * @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 function draw_text_at( $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];
- \imagefttext( $image, $size, 0, (int) ( $x - $text_width / 2 ), $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 );
- \imagestring( $image, $builtin_size, (int) ( $x - $text_width / 2 ), $y, $text, $color );
- }
}
}
diff --git a/src/stats/edit.js b/src/stats/edit.js
index 92f8326285..c971342615 100644
--- a/src/stats/edit.js
+++ b/src/stats/edit.js
@@ -3,7 +3,8 @@ import ServerSideRender from '@wordpress/server-side-render';
import { SelectControl, PanelBody, Disabled, ExternalLink, Button, TextControl } from '@wordpress/components';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
-import { useState, useEffect } from '@wordpress/element';
+import { useState, useEffect, useCallback } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
import { useUserOptions } from '../shared/use-user-options';
const currentYear = new Date().getFullYear();
@@ -22,19 +23,18 @@ function getYearOptions() {
}
/**
- * Build the image endpoint URL.
+ * Build the full URL for the stats image-url endpoint.
*
* @param {string} selectedUser The selected user ID.
* @param {number} displayYear The year to display.
- * @return {string} The image URL.
+ * @return {string} The full URL, or empty string if template unavailable.
*/
-function getImageUrl( selectedUser, displayYear ) {
- const userId = selectedUser === 'blog' ? 0 : selectedUser;
- // URL template from PHP with get_rest_url(), handles pretty/plain permalinks.
- const template = window._activityPubOptions?.statsImageUrl || '';
+function getImageUrlEndpoint( selectedUser, displayYear ) {
+ const template = window._activityPubOptions?.statsImageUrlEndpoint || '';
if ( ! template ) {
return '';
}
+ const userId = ! selectedUser || selectedUser === 'blog' ? 0 : selectedUser;
return template.replace( '{user_id}', userId ).replace( '{year}', displayYear );
}
@@ -71,7 +71,22 @@ export default function Edit( { attributes, setAttributes } ) {
}, [ usersOptions ] ); // eslint-disable-line react-hooks/exhaustive-deps
const displayYear = year || currentYear - 1;
- const imageUrl = getImageUrl( selectedUser || 'blog', displayYear );
+ const [ imageUrl, setImageUrl ] = useState( '' );
+
+ // Fetch the resolved image URL (cached file or REST endpoint).
+ const fetchImageUrl = useCallback( () => {
+ const endpoint = getImageUrlEndpoint( selectedUser || 'blog', displayYear );
+ if ( ! endpoint ) {
+ return;
+ }
+ apiFetch( { url: endpoint } )
+ .then( ( response ) => setImageUrl( response.url || '' ) )
+ .catch( () => setImageUrl( '' ) );
+ }, [ selectedUser, displayYear ] );
+
+ useEffect( () => {
+ fetchImageUrl();
+ }, [ fetchImageUrl ] );
const handleCopy = () => {
navigator.clipboard.writeText( imageUrl ).then( () => {
From bdf1eee54bae9d791810cf8a8056c44048332e6d Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:39:34 +0200
Subject: [PATCH 07/32] Handle missing GD library gracefully
- Add Stats_Image::is_available() central check for GD.
- Skip image attachment when GD is unavailable.
- Hide Share Image sidebar panel when GD is unavailable.
- Return empty statsImageUrlEndpoint so JS doesn't fetch.
- Include build files.
---
build/stats/block.json | 61 +++++++
build/stats/index.asset.php | 1 +
build/stats/index.js | 1 +
build/stats/render.php | 251 +++++++++++++++++++++++++++
build/stats/style-index-rtl.css | 1 +
build/stats/style-index.css | 1 +
includes/cache/class-stats-image.php | 17 ++
includes/class-blocks.php | 6 +-
src/stats/edit.js | 36 ++--
9 files changed, 357 insertions(+), 18 deletions(-)
create mode 100644 build/stats/block.json
create mode 100644 build/stats/index.asset.php
create mode 100644 build/stats/index.js
create mode 100644 build/stats/render.php
create mode 100644 build/stats/style-index-rtl.css
create mode 100644 build/stats/style-index.css
diff --git a/build/stats/block.json b/build/stats/block.json
new file mode 100644
index 0000000000..2cdd5bdbc3
--- /dev/null
+++ b/build/stats/block.json
@@ -0,0 +1,61 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "name": "activitypub/stats",
+ "apiVersion": 3,
+ "version": "unreleased",
+ "title": "ActivityPub Stats",
+ "category": "widgets",
+ "description": "Display your annual Fediverse stats as a shareable card.",
+ "textdomain": "activitypub",
+ "icon": "chart-bar",
+ "keywords": [
+ "fediverse",
+ "activitypub",
+ "stats",
+ "statistics",
+ "annual",
+ "year"
+ ],
+ "supports": {
+ "html": false,
+ "align": [
+ "wide",
+ "full"
+ ],
+ "color": {
+ "gradients": true,
+ "__experimentalDefaultControls": {
+ "background": true,
+ "text": true
+ }
+ },
+ "typography": {
+ "fontSize": true
+ },
+ "spacing": {
+ "margin": true,
+ "padding": true,
+ "__experimentalDefaultControls": {
+ "padding": true
+ }
+ },
+ "__experimentalBorder": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true,
+ "__experimentalSkipSerialization": true
+ }
+ },
+ "attributes": {
+ "selectedUser": {
+ "type": "string"
+ },
+ "year": {
+ "type": "number"
+ }
+ },
+ "editorScript": "file:./index.js",
+ "style": "file:./style-index.css",
+ "render": "file:./render.php"
+}
\ No newline at end of file
diff --git a/build/stats/index.asset.php b/build/stats/index.asset.php
new file mode 100644
index 0000000000..52718efa25
--- /dev/null
+++ b/build/stats/index.asset.php
@@ -0,0 +1 @@
+ array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-server-side-render'), 'version' => '61794b4cb4a4be8a32fd');
diff --git a/build/stats/index.js b/build/stats/index.js
new file mode 100644
index 0000000000..f7b5f3c44f
--- /dev/null
+++ b/build/stats/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var e,t={1868(e,t,i){const r=window.wp.blocks,s=window.wp.serverSideRender;var n=i.n(s);const a=window.wp.components,o=window.wp.blockEditor,l=window.wp.i18n,c=window.wp.element,u=window.wp.apiFetch;var d=i.n(u);const p=window.wp.data;const v=window.ReactJSXRuntime,b=(new Date).getFullYear();function h(){const e=[];for(let t=b;t>=b-5;t--)e.push({label:String(t),value:String(t)});return e}const g=JSON.parse('{"UU":"activitypub/stats"}');(0,r.registerBlockType)(g.UU,{edit:function({attributes:e,setAttributes:t}){const{selectedUser:i,year:r}=e,s=(0,o.useBlockProps)({style:{border:"none",borderRadius:void 0,padding:void 0,margin:void 0,background:void 0,backgroundColor:void 0,color:void 0}}),u=function({withInherit:e=!1}){const{enabled:t,namespace:i}=window._activityPubOptions||{},[r,s]=(0,c.useState)(!1),{fetchedUsers:n,isLoadingUsers:a}=(0,p.useSelect)(e=>{const{getUsers:i,getIsResolving:r}=e("core");return{fetchedUsers:t?.users?i({capabilities:"activitypub"}):null,isLoadingUsers:!!t?.users&&r("getUsers",[{capabilities:"activitypub"}])}},[t?.users]),o=(0,p.useSelect)(e=>n||a?null:e("core").getCurrentUser(),[n,a]);(0,c.useEffect)(()=>{n||a||!o||d()({path:`/${i}/actors/${o.id}`,method:"HEAD",headers:{Accept:"application/activity+json"},parse:!1}).then(()=>s(!0)).catch(()=>s(!1))},[n,a,o,i]);const u=(0,c.useMemo)(()=>n||(o&&r?[{id:o.id,name:o.name}]:[]),[n,o,r]);return(0,c.useMemo)(()=>{if(!u.length)return[];const i=[];return t?.blog&&n&&i.push({label:(0,l.__)("Blog","activitypub"),value:"blog"}),e&&t?.users&&n&&i.push({label:(0,l.__)("Dynamic User","activitypub"),value:"inherit"}),u.reduce((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e),i)},[u,t?.blog,t?.users,n,e])}({}),[g,w]=(0,c.useState)(!1);(0,c.useEffect)(()=>{!i&&u.length&&t({selectedUser:u[0].value})},[u]);const y=r||b-1,[f,_]=(0,c.useState)(""),m=(0,c.useCallback)(()=>{const e=function(e,t){const i=window._activityPubOptions?.statsImageUrlEndpoint||"";if(!i)return"";const r=e&&"blog"!==e?e:0;return i.replace("{user_id}",r).replace("{year}",t)}(i||"blog",y);e&&d()({url:e}).then(e=>_(e.url||"")).catch(()=>_(""))},[i,y]);return(0,c.useEffect)(()=>{m()},[m]),(0,v.jsxs)("div",{...s,children:[(0,v.jsxs)(o.InspectorControls,{children:[(0,v.jsxs)(a.PanelBody,{title:(0,l.__)("Settings","activitypub"),children:[u.length>1&&(0,v.jsx)(a.SelectControl,{label:(0,l.__)("Select User","activitypub"),value:i,options:u,onChange:e=>t({selectedUser:e})}),(0,v.jsx)(a.SelectControl,{label:(0,l.__)("Year","activitypub"),value:String(y),options:h(),onChange:e=>t({year:parseInt(e,10)})})]}),f&&(0,v.jsxs)(a.PanelBody,{title:(0,l.__)("Share Image","activitypub"),initialOpen:!1,children:[(0,v.jsx)("p",{className:"description",children:(0,l.__)("Use this URL to share your stats as an image on social media.","activitypub")}),(0,v.jsx)(a.TextControl,{__nextHasNoMarginBottom:!0,value:f,readOnly:!0,onClick:e=>e.target.select()}),(0,v.jsxs)("div",{style:{display:"flex",gap:"8px",alignItems:"center"},children:[(0,v.jsx)(a.Button,{variant:"secondary",onClick:()=>{navigator.clipboard.writeText(f).then(()=>{w(!0),setTimeout(()=>w(!1),2e3)})},children:g?(0,l.__)("Copied!","activitypub"):(0,l.__)("Copy URL","activitypub")}),(0,v.jsx)(a.ExternalLink,{href:f,children:(0,l.__)("Preview","activitypub")})]})]})]}),(0,v.jsx)(a.Disabled,{children:(0,v.jsx)(n(),{block:"activitypub/stats",attributes:{...e,year:y}})})]})}})}},i={};function r(e){var s=i[e];if(void 0!==s)return s.exports;var n=i[e]={exports:{}};return t[e](n,n.exports,r),n.exports}r.m=t,e=[],r.O=(t,i,s,n)=>{if(!i){var a=1/0;for(u=0;u=n)&&Object.keys(r.O).every(e=>r.O[e](i[l]))?i.splice(l--,1):(o=!1,n0&&e[u-1][2]>n;u--)e[u]=e[u-1];e[u]=[i,s,n]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var i in t)r.o(t,i)&&!r.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={85:0,721:0};r.O.j=t=>0===e[t];var t=(t,i)=>{var s,n,[a,o,l]=i,c=0;if(a.some(t=>0!==e[t])){for(s in o)r.o(o,s)&&(r.m[s]=o[s]);if(l)var u=l(r)}for(t&&t(i);cr(1868));s=r.O(s)})();
\ No newline at end of file
diff --git a/build/stats/render.php b/build/stats/render.php
new file mode 100644
index 0000000000..8db0e3c70b
--- /dev/null
+++ b/build/stats/render.php
@@ -0,0 +1,251 @@
+%s
%s
',
+ \esc_html__( 'Fediverse Stats', 'activitypub' ),
+ \sprintf(
+ /* translators: %d: The year */
+ \esc_html__( 'No stats available for %d. Stats are collected monthly and compiled at the end of each year.', 'activitypub' ),
+ (int) $stats_year
+ )
+ );
+ }
+ return;
+}
+
+// Get comment types for dynamic display.
+$comment_types = Statistics::get_comment_types_for_stats();
+
+// Calculate total engagement.
+$total_engagement = 0;
+foreach ( array_keys( $comment_types ) as $ct_slug ) {
+ $total_engagement += $summary[ $ct_slug . '_count' ] ?? 0;
+}
+
+// Most active month name.
+$most_active_month_name = '';
+if ( ! empty( $summary['most_active_month'] ) ) {
+ $most_active_month_name = gmdate( 'F', gmmktime( 0, 0, 0, $summary['most_active_month'], 1, $stats_year ) );
+}
+
+// Follower growth.
+$followers_start = $summary['followers_start'] ?? 0;
+$followers_end = $summary['followers_end'] ?? 0;
+$followers_net_change = $summary['followers_net_change'] ?? ( $followers_end - $followers_start );
+$change_sign = $followers_net_change >= 0 ? '+' : '';
+
+// Get actor webfinger for the card header.
+$actor = Actors::get_by_id( $user_id );
+
+if ( \is_wp_error( $actor ) ) {
+ // Fall back to direct model instantiation for blog/application actors.
+ if ( Actors::BLOG_USER_ID === $user_id ) {
+ $actor = new \Activitypub\Model\Blog();
+ } elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
+ $actor = new \Activitypub\Model\Application();
+ }
+}
+
+$actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
+
+// Site name for branding.
+$site_name = \get_bloginfo( 'name' );
+
+$block_id = 'activitypub-stats-' . \wp_unique_id();
+$title_text = \sprintf(
+ /* translators: %d: The year */
+ \__( 'Fediverse Stats %d', 'activitypub' ),
+ (int) $stats_year
+);
+
+// Build border styles manually since serialization is skipped.
+$border = $attributes['style']['border'] ?? array();
+$border_styles = array();
+
+$border_color = '';
+if ( ! empty( $border['color'] ) ) {
+ $border_color = $border['color'];
+ $border_styles[] = 'border-color:' . $border['color'];
+} elseif ( ! empty( $attributes['borderColor'] ) ) {
+ $border_color = 'var(--wp--preset--color--' . $attributes['borderColor'] . ')';
+ $border_styles[] = 'border-color:' . $border_color;
+}
+
+if ( ! empty( $border['width'] ) ) {
+ $border_styles[] = 'border-width:' . $border['width'];
+}
+
+if ( ! empty( $border['style'] ) ) {
+ $border_styles[] = 'border-style:' . $border['style'];
+}
+
+if ( ! empty( $border['radius'] ) ) {
+ if ( \is_array( $border['radius'] ) ) {
+ $border_styles[] = 'border-top-left-radius:' . ( $border['radius']['topLeft'] ?? 0 );
+ $border_styles[] = 'border-top-right-radius:' . ( $border['radius']['topRight'] ?? 0 );
+ $border_styles[] = 'border-bottom-right-radius:' . ( $border['radius']['bottomRight'] ?? 0 );
+ $border_styles[] = 'border-bottom-left-radius:' . ( $border['radius']['bottomLeft'] ?? 0 );
+ } else {
+ $border_styles[] = 'border-radius:' . $border['radius'];
+ }
+}
+
+// Pass border color to inner elements via CSS variable.
+if ( $border_color ) {
+ $border_styles[] = '--activitypub-stats--border-color:' . $border_color;
+}
+
+$wrapper_attrs = array(
+ 'id' => $block_id,
+ 'class' => 'activitypub-stats',
+);
+
+$extra_styles = ! empty( $border_styles ) ? \implode( ';', $border_styles ) : '';
+$wrapper_html = \get_block_wrapper_attributes( $wrapper_attrs );
+
+// Merge our border styles into the existing style attribute.
+if ( $extra_styles ) {
+ if ( \str_contains( $wrapper_html, 'style="' ) ) {
+ $wrapper_html = \str_replace( 'style="', 'style="' . \esc_attr( $extra_styles ) . ';', $wrapper_html );
+ } else {
+ $wrapper_html .= ' style="' . \esc_attr( $extra_styles ) . '"';
+ }
+}
+?>
+
+ data-year=""
+>
+
+
+
+
+
+ $type_info ) : ?>
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/stats/style-index-rtl.css b/build/stats/style-index-rtl.css
new file mode 100644
index 0000000000..3b244618c6
--- /dev/null
+++ b/build/stats/style-index-rtl.css
@@ -0,0 +1 @@
+.wp-block-activitypub-stats{background-color:var(--wp--preset--color--base,var(--wp--preset--color--white,#fff));color:var(--wp--preset--color--contrast,var(--wp--preset--color--black,inherit));max-width:var(--wp--style--global--content-size,600px);--activitypub-stats--border-color:color-mix(in srgb,currentcolor 20%,transparent)}.wp-block-activitypub-stats.alignwide{max-width:var(--wp--style--global--wide-size)}.wp-block-activitypub-stats.alignfull{max-width:none}.activitypub-stats__header{margin-bottom:1.5rem;text-align:center}.activitypub-stats__title{color:inherit;font-size:1.75em;font-weight:800;letter-spacing:-.02em;margin:0 0 .25rem}.activitypub-stats__subtitle{color:color-mix(in srgb,currentcolor 60%,transparent);font-size:1em;margin:0}.activitypub-stats__stats{display:flex;gap:1rem;margin-bottom:1.25rem}.activitypub-stats__stat{flex:1;padding:.75rem .5rem;text-align:center}.activitypub-stats__stat--highlight{border:1px solid var(--activitypub-stats--border-color);border-radius:8px}.activitypub-stats__stat-value{color:inherit;display:block;font-size:2em;font-weight:800;line-height:1.2}.activitypub-stats__stat--highlight .activitypub-stats__stat-value{font-size:2.5em}.activitypub-stats__stat-label{color:color-mix(in srgb,currentcolor 50%,transparent);display:block;font-size:.8em;letter-spacing:.05em;margin-top:.25rem;text-transform:uppercase}.activitypub-stats__engagement{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem}.activitypub-stats__engagement .activitypub-stats__stat{border:1px solid var(--activitypub-stats--border-color);border-radius:8px;flex:1 1 calc(33.333% - 0.5rem);min-width:5rem;padding:.625rem .5rem}.activitypub-stats__details{display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem}.activitypub-stats__detail{border:1px solid var(--activitypub-stats--border-color);border-radius:8px;flex:1 1 calc(50% - 0.5rem);min-width:7.5rem;padding:.875rem}.activitypub-stats__detail-label{color:color-mix(in srgb,currentcolor 50%,transparent);display:block;font-size:.75em;letter-spacing:.05em;margin-bottom:.25rem;text-transform:uppercase}.activitypub-stats__detail-value{color:inherit;display:block;font-size:1.25em;font-weight:700}.activitypub-stats__detail-value a{color:inherit;text-decoration:underline;text-underline-offset:.15em}.activitypub-stats__detail-value a:hover{color:color-mix(in srgb,currentcolor 70%,transparent)}.activitypub-stats__detail-value--negative,.activitypub-stats__detail-value--positive{color:inherit}.activitypub-stats__detail-extra{color:color-mix(in srgb,currentcolor 45%,transparent);display:block;font-size:.8em;margin-top:.125rem}.activitypub-stats__top-posts{margin-bottom:1.25rem}.activitypub-stats__section-title{color:color-mix(in srgb,currentcolor 50%,transparent);font-size:.85em;letter-spacing:.05em;margin:0 0 .75rem;text-transform:uppercase}.activitypub-stats__top-posts ol{list-style:decimal;margin:0;padding-right:1.5em}.activitypub-stats__top-posts li{padding:.5rem 0}.activitypub-stats__top-posts li:last-child{padding-bottom:0}.activitypub-stats__top-posts li a{color:inherit;text-decoration:none}.activitypub-stats__top-posts li a:hover{text-decoration:underline}.activitypub-stats__post-engagement{color:color-mix(in srgb,currentcolor 45%,transparent);font-size:.8em;margin-right:.25rem}.activitypub-stats__footer{margin-top:1.25rem;text-align:center}.activitypub-stats__branding{color:color-mix(in srgb,currentcolor 45%,transparent);font-size:.75em}
diff --git a/build/stats/style-index.css b/build/stats/style-index.css
new file mode 100644
index 0000000000..f9bbaec2e6
--- /dev/null
+++ b/build/stats/style-index.css
@@ -0,0 +1 @@
+.wp-block-activitypub-stats{background-color:var(--wp--preset--color--base,var(--wp--preset--color--white,#fff));color:var(--wp--preset--color--contrast,var(--wp--preset--color--black,inherit));max-width:var(--wp--style--global--content-size,600px);--activitypub-stats--border-color:color-mix(in srgb,currentcolor 20%,transparent)}.wp-block-activitypub-stats.alignwide{max-width:var(--wp--style--global--wide-size)}.wp-block-activitypub-stats.alignfull{max-width:none}.activitypub-stats__header{margin-bottom:1.5rem;text-align:center}.activitypub-stats__title{color:inherit;font-size:1.75em;font-weight:800;letter-spacing:-.02em;margin:0 0 .25rem}.activitypub-stats__subtitle{color:color-mix(in srgb,currentcolor 60%,transparent);font-size:1em;margin:0}.activitypub-stats__stats{display:flex;gap:1rem;margin-bottom:1.25rem}.activitypub-stats__stat{flex:1;padding:.75rem .5rem;text-align:center}.activitypub-stats__stat--highlight{border:1px solid var(--activitypub-stats--border-color);border-radius:8px}.activitypub-stats__stat-value{color:inherit;display:block;font-size:2em;font-weight:800;line-height:1.2}.activitypub-stats__stat--highlight .activitypub-stats__stat-value{font-size:2.5em}.activitypub-stats__stat-label{color:color-mix(in srgb,currentcolor 50%,transparent);display:block;font-size:.8em;letter-spacing:.05em;margin-top:.25rem;text-transform:uppercase}.activitypub-stats__engagement{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem}.activitypub-stats__engagement .activitypub-stats__stat{border:1px solid var(--activitypub-stats--border-color);border-radius:8px;flex:1 1 calc(33.333% - 0.5rem);min-width:5rem;padding:.625rem .5rem}.activitypub-stats__details{display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem}.activitypub-stats__detail{border:1px solid var(--activitypub-stats--border-color);border-radius:8px;flex:1 1 calc(50% - 0.5rem);min-width:7.5rem;padding:.875rem}.activitypub-stats__detail-label{color:color-mix(in srgb,currentcolor 50%,transparent);display:block;font-size:.75em;letter-spacing:.05em;margin-bottom:.25rem;text-transform:uppercase}.activitypub-stats__detail-value{color:inherit;display:block;font-size:1.25em;font-weight:700}.activitypub-stats__detail-value a{color:inherit;text-decoration:underline;text-underline-offset:.15em}.activitypub-stats__detail-value a:hover{color:color-mix(in srgb,currentcolor 70%,transparent)}.activitypub-stats__detail-value--negative,.activitypub-stats__detail-value--positive{color:inherit}.activitypub-stats__detail-extra{color:color-mix(in srgb,currentcolor 45%,transparent);display:block;font-size:.8em;margin-top:.125rem}.activitypub-stats__top-posts{margin-bottom:1.25rem}.activitypub-stats__section-title{color:color-mix(in srgb,currentcolor 50%,transparent);font-size:.85em;letter-spacing:.05em;margin:0 0 .75rem;text-transform:uppercase}.activitypub-stats__top-posts ol{list-style:decimal;margin:0;padding-left:1.5em}.activitypub-stats__top-posts li{padding:.5rem 0}.activitypub-stats__top-posts li:last-child{padding-bottom:0}.activitypub-stats__top-posts li a{color:inherit;text-decoration:none}.activitypub-stats__top-posts li a:hover{text-decoration:underline}.activitypub-stats__post-engagement{color:color-mix(in srgb,currentcolor 45%,transparent);font-size:.8em;margin-left:.25rem}.activitypub-stats__footer{margin-top:1.25rem;text-align:center}.activitypub-stats__branding{color:color-mix(in srgb,currentcolor 45%,transparent);font-size:.75em}
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 7ab27d7321..83080b1485 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -40,6 +40,15 @@ class Stats_Image {
*/
const HEIGHT = 630;
+ /**
+ * 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.
*
@@ -50,6 +59,10 @@ class Stats_Image {
* @return string|\WP_Error The public URL or error.
*/
public static function get_url( $user_id, $year, $color_overrides = array() ) {
+ 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 ( ! self::is_enabled() ) {
$url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
@@ -116,6 +129,10 @@ private static function is_enabled() {
* @return \WP_Error|void Error on failure, exits on success.
*/
public static function serve( $user_id, $year, $color_overrides = array() ) {
+ if ( ! self::is_available() ) {
+ return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
+ }
+
$cache_key = self::get_cache_key( $user_id, $year, $color_overrides );
$cached = self::get_cached( $cache_key );
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 46583f5418..13f8b8fb07 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -103,7 +103,7 @@ public static function enqueue_editor_assets() {
'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
- 'statsImageUrlEndpoint' => \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/{user_id}/{year}' ),
+ 'statsImageUrlEndpoint' => Stats_Image::is_available() ? \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/{user_id}/{year}' ) : '',
);
wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
@@ -1052,6 +1052,10 @@ public static function generate_reply_link( $block_content, $block ) {
* @return array The attachments with stats images appended.
*/
public static function add_stats_image_attachment( $attachments, $post ) {
+ if ( ! Stats_Image::is_available() ) {
+ return $attachments;
+ }
+
$blocks = \parse_blocks( $post->post_content );
foreach ( $blocks as $block ) {
diff --git a/src/stats/edit.js b/src/stats/edit.js
index c971342615..bc1dece5b3 100644
--- a/src/stats/edit.js
+++ b/src/stats/edit.js
@@ -114,23 +114,25 @@ export default function Edit( { attributes, setAttributes } ) {
onChange={ ( value ) => setAttributes( { year: parseInt( value, 10 ) } ) }
/>
-
-
- { __( 'Use this URL to share your stats as an image on social media.', 'activitypub' ) }
-
- e.target.select() }
- />
-
-
- { __( 'Preview', 'activitypub' ) }
-
-
+ { imageUrl && (
+
+
+ { __( 'Use this URL to share your stats as an image on social media.', 'activitypub' ) }
+
+ e.target.select() }
+ />
+
+
+ { __( 'Preview', 'activitypub' ) }
+
+
+ ) }
From 13beb3252fd4134bca0d129bef555672b84824c3 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:40:56 +0200
Subject: [PATCH 08/32] Skip image attachment when stats are unavailable for
the selected user
---
includes/class-blocks.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 13f8b8fb07..afa680d2bd 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -1067,6 +1067,10 @@ public static function add_stats_image_attachment( $attachments, $post ) {
$year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
$url = Stats_Image::get_url( $user_id, $year );
+ if ( \is_wp_error( $url ) ) {
+ continue;
+ }
+
// Determine mime type from URL extension.
$mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
From 68f359a589673240773d15409c724ba1494031ab Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:46:32 +0200
Subject: [PATCH 09/32] Fix stats image tests: skip without GD, seed stats data
---
.../tests/includes/class-test-blocks.php | 80 ++++++++++++++++++-
1 file changed, 76 insertions(+), 4 deletions(-)
diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php
index f9e950dcae..2a9f5eb534 100644
--- a/tests/phpunit/tests/includes/class-test-blocks.php
+++ b/tests/phpunit/tests/includes/class-test-blocks.php
@@ -1034,6 +1034,30 @@ public function test_render_extra_fields_block_preserves_html() {
* @covers ::add_stats_image_attachment
*/
public function test_add_stats_image_attachment() {
+ if ( ! \Activitypub\Cache\Stats_Image::is_available() ) {
+ $this->markTestSkipped( 'GD library is not available.' );
+ }
+
+ // Seed stats so get_url() can generate the image.
+ \update_option(
+ 'activitypub_stats_0_2025_annual',
+ array(
+ 'posts_count' => 10,
+ 'followers_start' => 0,
+ 'followers_end' => 5,
+ 'followers_net_change' => 5,
+ 'most_active_month' => 1,
+ 'top_multiplicator' => null,
+ 'top_posts' => array(),
+ 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ),
+ 'like_count' => 5,
+ 'repost_count' => 2,
+ 'comment_count' => 1,
+ 'quote_count' => 0,
+ ),
+ false
+ );
+
$post = self::factory()->post->create_and_get(
array(
'post_content' => '',
@@ -1045,8 +1069,7 @@ public function test_add_stats_image_attachment() {
$this->assertCount( 1, $attachments );
$this->assertSame( 'Image', $attachments[0]['type'] );
- $this->assertSame( 'image/png', $attachments[0]['mediaType'] );
- $this->assertStringContainsString( 'stats/image/0/2025', $attachments[0]['url'] );
+ $this->assertStringContainsString( 'stats', $attachments[0]['url'] );
$this->assertStringContainsString( '2025', $attachments[0]['name'] );
}
@@ -1074,6 +1097,30 @@ public function test_add_stats_image_attachment_no_block() {
* @covers ::add_stats_image_attachment
*/
public function test_add_stats_image_attachment_preserves_existing() {
+ if ( ! \Activitypub\Cache\Stats_Image::is_available() ) {
+ $this->markTestSkipped( 'GD library is not available.' );
+ }
+
+ // Seed stats.
+ \update_option(
+ 'activitypub_stats_0_2025_annual',
+ array(
+ 'posts_count' => 10,
+ 'followers_start' => 0,
+ 'followers_end' => 5,
+ 'followers_net_change' => 5,
+ 'most_active_month' => 1,
+ 'top_multiplicator' => null,
+ 'top_posts' => array(),
+ 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ),
+ 'like_count' => 5,
+ 'repost_count' => 2,
+ 'comment_count' => 1,
+ 'quote_count' => 0,
+ ),
+ false
+ );
+
$post = self::factory()->post->create_and_get(
array(
'post_content' => '',
@@ -1092,7 +1139,7 @@ public function test_add_stats_image_attachment_preserves_existing() {
$this->assertCount( 2, $attachments );
$this->assertSame( 'https://example.com/photo.jpg', $attachments[0]['url'] );
- $this->assertStringContainsString( 'stats/image', $attachments[1]['url'] );
+ $this->assertStringContainsString( 'stats', $attachments[1]['url'] );
}
/**
@@ -1101,6 +1148,30 @@ public function test_add_stats_image_attachment_preserves_existing() {
* @covers ::add_stats_image_attachment
*/
public function test_add_stats_image_attachment_with_user_id() {
+ if ( ! \Activitypub\Cache\Stats_Image::is_available() ) {
+ $this->markTestSkipped( 'GD library is not available.' );
+ }
+
+ // Seed stats for user 1.
+ \update_option(
+ 'activitypub_stats_1_2024_annual',
+ array(
+ 'posts_count' => 10,
+ 'followers_start' => 0,
+ 'followers_end' => 5,
+ 'followers_net_change' => 5,
+ 'most_active_month' => 1,
+ 'top_multiplicator' => null,
+ 'top_posts' => array(),
+ 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ),
+ 'like_count' => 5,
+ 'repost_count' => 2,
+ 'comment_count' => 1,
+ 'quote_count' => 0,
+ ),
+ false
+ );
+
$post = self::factory()->post->create_and_get(
array(
'post_content' => '',
@@ -1111,7 +1182,8 @@ public function test_add_stats_image_attachment_with_user_id() {
$attachments = Blocks::add_stats_image_attachment( array(), $post );
$this->assertCount( 1, $attachments );
- $this->assertStringContainsString( 'stats/image/1/2024', $attachments[0]['url'] );
+ $this->assertStringContainsString( 'stats', $attachments[0]['url'] );
+ $this->assertStringContainsString( '2024', $attachments[0]['name'] );
}
/**
From 12033fb54b975fc323fba189869f51f029eeef06 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:49:01 +0200
Subject: [PATCH 10/32] Add JS tests for stats block editor helpers
---
src/stats/__tests__/edit.test.js | 102 +++++++++++++++++++++++++++++++
1 file changed, 102 insertions(+)
create mode 100644 src/stats/__tests__/edit.test.js
diff --git a/src/stats/__tests__/edit.test.js b/src/stats/__tests__/edit.test.js
new file mode 100644
index 0000000000..73a31c7709
--- /dev/null
+++ b/src/stats/__tests__/edit.test.js
@@ -0,0 +1,102 @@
+describe( 'Stats block helpers', () => {
+ beforeEach( () => {
+ window._activityPubOptions = {
+ statsImageUrlEndpoint: 'http://example.com/?rest_route=/activitypub/1.0/stats/image-url/{user_id}/{year}',
+ };
+ } );
+
+ afterEach( () => {
+ delete window._activityPubOptions;
+ } );
+
+ describe( 'getImageUrlEndpoint', () => {
+ // Inline the function since it's not exported.
+ function getImageUrlEndpoint( selectedUser, displayYear ) {
+ const template = window._activityPubOptions?.statsImageUrlEndpoint || '';
+ if ( ! template ) {
+ return '';
+ }
+ const userId = ! selectedUser || selectedUser === 'blog' ? 0 : selectedUser;
+ return template.replace( '{user_id}', userId ).replace( '{year}', displayYear );
+ }
+
+ test( 'converts "blog" to user ID 0', () => {
+ const url = getImageUrlEndpoint( 'blog', 2025 );
+ expect( url ).toContain( '/0/2025' );
+ } );
+
+ test( 'converts undefined to user ID 0', () => {
+ const url = getImageUrlEndpoint( undefined, 2025 );
+ expect( url ).toContain( '/0/2025' );
+ } );
+
+ test( 'converts null to user ID 0', () => {
+ const url = getImageUrlEndpoint( null, 2025 );
+ expect( url ).toContain( '/0/2025' );
+ } );
+
+ test( 'converts empty string to user ID 0', () => {
+ const url = getImageUrlEndpoint( '', 2025 );
+ expect( url ).toContain( '/0/2025' );
+ } );
+
+ test( 'passes numeric user ID through', () => {
+ const url = getImageUrlEndpoint( '1', 2024 );
+ expect( url ).toContain( '/1/2024' );
+ } );
+
+ test( 'replaces both placeholders', () => {
+ const url = getImageUrlEndpoint( '42', 2023 );
+ expect( url ).toBe( 'http://example.com/?rest_route=/activitypub/1.0/stats/image-url/42/2023' );
+ } );
+
+ test( 'returns empty string when template is unavailable', () => {
+ delete window._activityPubOptions;
+ const url = getImageUrlEndpoint( 'blog', 2025 );
+ expect( url ).toBe( '' );
+ } );
+
+ test( 'returns empty string when options is undefined', () => {
+ window._activityPubOptions = {};
+ const url = getImageUrlEndpoint( 'blog', 2025 );
+ expect( url ).toBe( '' );
+ } );
+
+ test( 'works with pretty permalink template', () => {
+ window._activityPubOptions = {
+ statsImageUrlEndpoint: 'http://example.com/wp-json/activitypub/1.0/stats/image-url/{user_id}/{year}',
+ };
+ const url = getImageUrlEndpoint( 'blog', 2025 );
+ expect( url ).toBe( 'http://example.com/wp-json/activitypub/1.0/stats/image-url/0/2025' );
+ } );
+ } );
+
+ describe( 'getYearOptions', () => {
+ function getYearOptions() {
+ const currentYear = new Date().getFullYear();
+ const options = [];
+ for ( let y = currentYear; y >= currentYear - 5; y-- ) {
+ options.push( { label: String( y ), value: String( y ) } );
+ }
+ return options;
+ }
+
+ test( 'returns 6 year options', () => {
+ const options = getYearOptions();
+ expect( options ).toHaveLength( 6 );
+ } );
+
+ test( 'starts with current year', () => {
+ const options = getYearOptions();
+ expect( options[ 0 ].value ).toBe( String( new Date().getFullYear() ) );
+ } );
+
+ test( 'options have label and value as strings', () => {
+ const options = getYearOptions();
+ options.forEach( ( option ) => {
+ expect( typeof option.label ).toBe( 'string' );
+ expect( typeof option.value ).toBe( 'string' );
+ } );
+ } );
+ } );
+} );
From 79b26d11fa1d66ddc33a777296f053e22c149d39 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 13:52:13 +0200
Subject: [PATCH 11/32] Fix PHP 8.5 imagedestroy deprecation and test
assertions
- Only call imagedestroy() on PHP < 8.0 where GD images are resources.
- Fix URL assertion tests to work with both cached file URLs and REST
endpoint URLs.
---
includes/cache/class-stats-image.php | 6 +++++-
tests/phpunit/tests/includes/class-test-blocks.php | 8 +++++---
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 83080b1485..759fdcb1ac 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -416,7 +416,11 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
// Save to temp file.
$tmp_file = \wp_tempnam( 'activitypub-stats-' );
\imagepng( $image, $tmp_file );
- \imagedestroy( $image );
+
+ // imagedestroy() is deprecated since PHP 8.5 and a no-op since 8.0.
+ if ( \PHP_VERSION_ID < 80000 ) {
+ \imagedestroy( $image );
+ }
return $tmp_file;
}
diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php
index 2a9f5eb534..08ef75a95f 100644
--- a/tests/phpunit/tests/includes/class-test-blocks.php
+++ b/tests/phpunit/tests/includes/class-test-blocks.php
@@ -1194,7 +1194,9 @@ public function test_add_stats_image_attachment_with_user_id() {
public function test_get_stats_image_url() {
$url = Blocks::get_stats_image_url( 0, 2025 );
- $this->assertStringContainsString( 'stats/image/0/2025', $url );
+ // URL contains the stats path (either cached file or REST endpoint).
+ $this->assertStringContainsString( 'stats', $url );
+ $this->assertStringContainsString( '2025', $url );
}
/**
@@ -1207,8 +1209,8 @@ public function test_get_stats_image_url_plain_permalinks() {
$url = Blocks::get_stats_image_url( 1, 2024 );
- $this->assertStringContainsString( 'stats/image/1/2024', $url );
- $this->assertStringContainsString( 'rest_route', $url );
+ $this->assertStringContainsString( 'stats', $url );
+ $this->assertStringContainsString( '2024', $url );
// Restore.
\update_option( 'permalink_structure', '/%postname%/' );
From 543e419ca065707f638f3ef1a4c41c625129a1b2 Mon Sep 17 00:00:00 2001
From: Automattic Bot
Date: Wed, 1 Apr 2026 14:16:01 +0200
Subject: [PATCH 12/32] Add changelog
---
.github/changelog/3126-from-description | 4 ++++
1 file changed, 4 insertions(+)
create mode 100644 .github/changelog/3126-from-description
diff --git a/.github/changelog/3126-from-description b/.github/changelog/3126-from-description
new file mode 100644
index 0000000000..ca39fe24b7
--- /dev/null
+++ b/.github/changelog/3126-from-description
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add a stats block that displays annual Fediverse statistics as a card on the site and as a shareable image on the Fediverse, with automatic color and font adoption from the site's theme.
From 423564b80c7963891cdfe25bb85e0241312e7946 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 14:29:18 +0200
Subject: [PATCH 13/32] Fix CSS injection and null user_id in stats block
- Sanitize border attribute values individually with regex validation
before writing to inline styles. Prevents CSS injection from imported
or migrated post content.
- Guard against null user_id from get_user_id() when selectedUser is
'inherit' outside a loop context.
---
build/stats/render.php | 28 ++++++++++++++++++----------
includes/class-blocks.php | 9 +++++++--
src/stats/render.php | 28 ++++++++++++++++++----------
3 files changed, 43 insertions(+), 22 deletions(-)
diff --git a/build/stats/render.php b/build/stats/render.php
index 8db0e3c70b..66cba63dcf 100644
--- a/build/stats/render.php
+++ b/build/stats/render.php
@@ -86,34 +86,42 @@
(int) $stats_year
);
-// Build border styles manually since serialization is skipped.
+/*
+ * Build border styles manually since serialization is skipped.
+ * Each value is sanitized individually to prevent CSS injection from
+ * imported or migrated post content.
+ */
$border = $attributes['style']['border'] ?? array();
$border_styles = array();
$border_color = '';
-if ( ! empty( $border['color'] ) ) {
+if ( ! empty( $border['color'] ) && \preg_match( '/^(#[0-9a-f]{3,8}|var\(--[\w-]+\))$/i', $border['color'] ) ) {
$border_color = $border['color'];
$border_styles[] = 'border-color:' . $border['color'];
-} elseif ( ! empty( $attributes['borderColor'] ) ) {
+} elseif ( ! empty( $attributes['borderColor'] ) && \preg_match( '/^[a-z0-9-]+$/i', $attributes['borderColor'] ) ) {
$border_color = 'var(--wp--preset--color--' . $attributes['borderColor'] . ')';
$border_styles[] = 'border-color:' . $border_color;
}
-if ( ! empty( $border['width'] ) ) {
+if ( ! empty( $border['width'] ) && \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['width'] ) ) {
$border_styles[] = 'border-width:' . $border['width'];
}
-if ( ! empty( $border['style'] ) ) {
+$allowed_styles = array( 'none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset' );
+if ( ! empty( $border['style'] ) && \in_array( $border['style'], $allowed_styles, true ) ) {
$border_styles[] = 'border-style:' . $border['style'];
}
if ( ! empty( $border['radius'] ) ) {
if ( \is_array( $border['radius'] ) ) {
- $border_styles[] = 'border-top-left-radius:' . ( $border['radius']['topLeft'] ?? 0 );
- $border_styles[] = 'border-top-right-radius:' . ( $border['radius']['topRight'] ?? 0 );
- $border_styles[] = 'border-bottom-right-radius:' . ( $border['radius']['bottomRight'] ?? 0 );
- $border_styles[] = 'border-bottom-left-radius:' . ( $border['radius']['bottomLeft'] ?? 0 );
- } else {
+ foreach ( array( 'topLeft', 'topRight', 'bottomRight', 'bottomLeft' ) as $corner ) {
+ $value = $border['radius'][ $corner ] ?? '0';
+ if ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $value ) ) {
+ $css_corner = \preg_replace( '/([A-Z])/', '-$1', $corner );
+ $border_styles[] = 'border-' . \strtolower( $css_corner ) . '-radius:' . $value;
+ }
+ }
+ } elseif ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['radius'] ) ) {
$border_styles[] = 'border-radius:' . $border['radius'];
}
}
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index afa680d2bd..d746fdd5d5 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -1064,8 +1064,13 @@ public static function add_stats_image_attachment( $attachments, $post ) {
}
$user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
- $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
- $url = Stats_Image::get_url( $user_id, $year );
+
+ if ( null === $user_id ) {
+ continue;
+ }
+
+ $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
+ $url = Stats_Image::get_url( $user_id, $year );
if ( \is_wp_error( $url ) ) {
continue;
diff --git a/src/stats/render.php b/src/stats/render.php
index 8db0e3c70b..66cba63dcf 100644
--- a/src/stats/render.php
+++ b/src/stats/render.php
@@ -86,34 +86,42 @@
(int) $stats_year
);
-// Build border styles manually since serialization is skipped.
+/*
+ * Build border styles manually since serialization is skipped.
+ * Each value is sanitized individually to prevent CSS injection from
+ * imported or migrated post content.
+ */
$border = $attributes['style']['border'] ?? array();
$border_styles = array();
$border_color = '';
-if ( ! empty( $border['color'] ) ) {
+if ( ! empty( $border['color'] ) && \preg_match( '/^(#[0-9a-f]{3,8}|var\(--[\w-]+\))$/i', $border['color'] ) ) {
$border_color = $border['color'];
$border_styles[] = 'border-color:' . $border['color'];
-} elseif ( ! empty( $attributes['borderColor'] ) ) {
+} elseif ( ! empty( $attributes['borderColor'] ) && \preg_match( '/^[a-z0-9-]+$/i', $attributes['borderColor'] ) ) {
$border_color = 'var(--wp--preset--color--' . $attributes['borderColor'] . ')';
$border_styles[] = 'border-color:' . $border_color;
}
-if ( ! empty( $border['width'] ) ) {
+if ( ! empty( $border['width'] ) && \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['width'] ) ) {
$border_styles[] = 'border-width:' . $border['width'];
}
-if ( ! empty( $border['style'] ) ) {
+$allowed_styles = array( 'none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset' );
+if ( ! empty( $border['style'] ) && \in_array( $border['style'], $allowed_styles, true ) ) {
$border_styles[] = 'border-style:' . $border['style'];
}
if ( ! empty( $border['radius'] ) ) {
if ( \is_array( $border['radius'] ) ) {
- $border_styles[] = 'border-top-left-radius:' . ( $border['radius']['topLeft'] ?? 0 );
- $border_styles[] = 'border-top-right-radius:' . ( $border['radius']['topRight'] ?? 0 );
- $border_styles[] = 'border-bottom-right-radius:' . ( $border['radius']['bottomRight'] ?? 0 );
- $border_styles[] = 'border-bottom-left-radius:' . ( $border['radius']['bottomLeft'] ?? 0 );
- } else {
+ foreach ( array( 'topLeft', 'topRight', 'bottomRight', 'bottomLeft' ) as $corner ) {
+ $value = $border['radius'][ $corner ] ?? '0';
+ if ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $value ) ) {
+ $css_corner = \preg_replace( '/([A-Z])/', '-$1', $corner );
+ $border_styles[] = 'border-' . \strtolower( $css_corner ) . '-radius:' . $value;
+ }
+ }
+ } elseif ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['radius'] ) ) {
$border_styles[] = 'border-radius:' . $border['radius'];
}
}
From cddda796a7665fb8b014342c4b76b0a67e9e7513 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 14:40:20 +0200
Subject: [PATCH 14/32] Address code review suggestions
- Add tests for image-url endpoint (get_url JSON response, 404 case).
- Derive image-url route from rest_base instead of hardcoding.
- Remove dead Blocks::get_stats_image_url(), update tests to use
Stats_Image::get_url() directly.
- Add comment explaining stats image intentionally bypasses the max
attachments limit since it replaces the stripped block content.
- Wrap require_once for wp-admin/includes/file.php in function_exists
check for wp_tempnam.
---
includes/cache/class-stats-image.php | 4 +-
includes/class-blocks.php | 29 +++----------
.../rest/class-stats-image-controller.php | 2 +-
.../tests/includes/class-test-blocks.php | 22 +++++++---
.../class-test-stats-image-controller.php | 42 +++++++++++++++++++
5 files changed, 68 insertions(+), 31 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 759fdcb1ac..8239de5270 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -197,7 +197,9 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
$actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
$site_name = \get_bloginfo( 'name' );
- require_once ABSPATH . 'wp-admin/includes/file.php';
+ if ( ! \function_exists( 'wp_tempnam' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
$tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year, $color_overrides );
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index d746fdd5d5..39fb09b494 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -1056,6 +1056,12 @@ public static function add_stats_image_attachment( $attachments, $post ) {
return $attachments;
}
+ /*
+ * The stats image intentionally bypasses the `activitypub_max_image_attachments`
+ * limit because it replaces the block content rather than being an inline image
+ * extracted from the post. It is always appended so that the share-pic is
+ * included in the federated activity regardless of the attachment cap.
+ */
$blocks = \parse_blocks( $post->post_content );
foreach ( $blocks as $block ) {
@@ -1094,29 +1100,6 @@ public static function add_stats_image_attachment( $attachments, $post ) {
return $attachments;
}
- /**
- * Get the stats image URL for a given user and year.
- *
- * Returns the direct cached file URL if available, otherwise
- * falls back to the REST endpoint URL.
- *
- * @since unreleased
- *
- * @param int $user_id The user ID.
- * @param int $year The year.
- *
- * @return string The image URL.
- */
- public static function get_stats_image_url( $user_id, $year ) {
- $url = Stats_Image::get_url( $user_id, $year );
-
- if ( \is_wp_error( $url ) ) {
- return \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
- }
-
- return $url;
- }
-
/**
* Transform Embed blocks to block level link.
*
diff --git a/includes/rest/class-stats-image-controller.php b/includes/rest/class-stats-image-controller.php
index c21352036c..51de4cc197 100644
--- a/includes/rest/class-stats-image-controller.php
+++ b/includes/rest/class-stats-image-controller.php
@@ -88,7 +88,7 @@ public function register_routes() {
// Return the image URL as JSON.
\register_rest_route(
$this->namespace,
- '/stats/image-url' . $route_pattern,
+ '/' . $this->rest_base . '-url' . $route_pattern,
array(
array(
'methods' => \WP_REST_Server::READABLE,
diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php
index 08ef75a95f..f792f62787 100644
--- a/tests/phpunit/tests/includes/class-test-blocks.php
+++ b/tests/phpunit/tests/includes/class-test-blocks.php
@@ -1187,12 +1187,17 @@ public function test_add_stats_image_attachment_with_user_id() {
}
/**
- * Test get_stats_image_url generates valid URL.
+ * Test Stats_Image::get_url generates valid URL.
*
- * @covers ::get_stats_image_url
+ * @covers \Activitypub\Cache\Stats_Image::get_url
*/
public function test_get_stats_image_url() {
- $url = Blocks::get_stats_image_url( 0, 2025 );
+ $url = \Activitypub\Cache\Stats_Image::get_url( 0, 2025 );
+
+ if ( \is_wp_error( $url ) ) {
+ // GD not available; fall back to REST endpoint URL.
+ $url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/0/2025' );
+ }
// URL contains the stats path (either cached file or REST endpoint).
$this->assertStringContainsString( 'stats', $url );
@@ -1200,14 +1205,19 @@ public function test_get_stats_image_url() {
}
/**
- * Test get_stats_image_url works with plain permalinks.
+ * Test Stats_Image::get_url works with plain permalinks.
*
- * @covers ::get_stats_image_url
+ * @covers \Activitypub\Cache\Stats_Image::get_url
*/
public function test_get_stats_image_url_plain_permalinks() {
\update_option( 'permalink_structure', '' );
- $url = Blocks::get_stats_image_url( 1, 2024 );
+ $url = \Activitypub\Cache\Stats_Image::get_url( 1, 2024 );
+
+ if ( \is_wp_error( $url ) ) {
+ // GD not available; fall back to REST endpoint URL.
+ $url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/1/2024' );
+ }
$this->assertStringContainsString( 'stats', $url );
$this->assertStringContainsString( '2024', $url );
diff --git a/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php b/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
index d534882011..be77ff7719 100644
--- a/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
+++ b/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
@@ -83,6 +83,7 @@ private function seed_stats( $user_id, $year ) {
public function test_register_routes() {
$routes = \rest_get_server()->get_routes();
$this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/(?P[\\d]+)/(?P[\\d]{4})', $routes );
+ $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/(?P[\\d]+)/(?P[\\d]{4})', $routes );
}
/**
@@ -186,4 +187,45 @@ public function test_invalid_year_format() {
// Route pattern requires 4 digits, so this should 404 (no route match).
$this->assertEquals( 404, $response->get_status() );
}
+
+ /**
+ * Test image-url endpoint returns a URL when stats exist.
+ *
+ * @covers ::get_url
+ */
+ public function test_get_url() {
+ if ( ! \Activitypub\Cache\Stats_Image::is_available() ) {
+ $this->markTestSkipped( 'GD library is not available.' );
+ }
+
+ $this->seed_stats( Actors::BLOG_USER_ID, 2025 );
+
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/' . Actors::BLOG_USER_ID . '/2025' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'url', $data );
+ $this->assertStringContainsString( 'stats', $data['url'] );
+ }
+
+ /**
+ * Test image-url endpoint returns 404 when no stats exist.
+ *
+ * @covers ::get_url
+ */
+ public function test_get_url_no_stats() {
+ if ( ! \Activitypub\Cache\Stats_Image::is_available() ) {
+ $this->markTestSkipped( 'GD library is not available.' );
+ }
+
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/' . self::$user_id . '/1999' );
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'no_stats', $data['code'] );
+ }
}
From 4c3705b02bbd4a3e38e0b6e1543a39158b83dec3 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 14:46:53 +0200
Subject: [PATCH 15/32] Refactor Stats_Image to extend File cache base class
- Extend Cache\File for storage paths, optimization, glob lookup,
filesystem access, and is_enabled() filter.
- Reuse File::optimize_image() for WebP conversion instead of custom
optimize_and_store().
- Reuse File::get_storage_paths() for basedir/baseurl resolution
instead of custom path_to_url().
- Reuse File::get_file_mime_type() for MIME detection.
- Reuse File::escape_glob_pattern() + glob for cached file lookup.
- Reuse File::get_filesystem()->move() for temp file handling.
- Merge draw_text_centered/draw_text_at into single draw_text method.
- Simplify parse_hex with sscanf.
- Extract find_ttf_in_families/find_ttf_in_font_library helpers.
- Add image-url endpoint test and route registration test.
- Remove dead Blocks::get_stats_image_url(), update tests.
- Add comment about max attachments bypass.
---
includes/cache/class-stats-image.php | 461 ++++++++++++---------------
1 file changed, 203 insertions(+), 258 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 8239de5270..87aaf1d878 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -15,16 +15,10 @@
* 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 {
-
- /**
- * Base directory for cached stats images relative to uploads.
- *
- * @var string
- */
- const BASE_DIR = '/activitypub/stats/';
+class Stats_Image extends File {
/**
* Image width in pixels.
@@ -40,6 +34,44 @@ class Stats_Image {
*/
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.
*
@@ -64,7 +96,7 @@ public static function get_url( $user_id, $year, $color_overrides = array() ) {
}
// If local caching is disabled, use the REST endpoint for on-the-fly generation.
- if ( ! self::is_enabled() ) {
+ if ( ! static::is_enabled() ) {
$url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
/**
@@ -81,42 +113,33 @@ public static function get_url( $user_id, $year, $color_overrides = array() ) {
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
}
- $cache_key = self::get_cache_key( $user_id, $year, $color_overrides );
- $cached = self::get_cached( $cache_key );
+ $hash = self::get_hash( $color_overrides );
+ $paths = static::get_storage_paths( $user_id );
- if ( ! $cached ) {
- $cached = self::generate( $user_id, $year, $color_overrides );
+ // 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 );
}
- if ( \is_wp_error( $cached ) ) {
- return $cached;
+ // Generate the image.
+ $result = self::generate( $user_id, $year, $color_overrides );
+
+ if ( \is_wp_error( $result ) ) {
+ return $result;
}
- $url = self::path_to_url( $cached['path'] );
+ $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 );
}
- /**
- * Check if stats image caching is enabled.
- *
- * Uses the same filter pattern as other cache types:
- * `activitypub_cache_stats_image_enabled`.
- *
- * @return bool Whether caching is enabled.
- */
- private static function is_enabled() {
- /**
- * Filters whether stats image caching is enabled.
- *
- * @since unreleased
- *
- * @param bool $enabled Whether caching is enabled. Default true.
- */
- return (bool) \apply_filters( 'activitypub_cache_stats_image_enabled', true );
- }
-
/**
* Serve a stats image, generating it if needed.
*
@@ -133,22 +156,29 @@ public static function serve( $user_id, $year, $color_overrides = array() ) {
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
}
- $cache_key = self::get_cache_key( $user_id, $year, $color_overrides );
- $cached = self::get_cached( $cache_key );
+ $hash = self::get_hash( $color_overrides );
+ $paths = static::get_storage_paths( $user_id );
- if ( ! $cached ) {
- $cached = self::generate( $user_id, $year, $color_overrides );
+ // 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, $color_overrides );
}
- if ( \is_wp_error( $cached ) ) {
- return $cached;
+ if ( \is_wp_error( $file ) ) {
+ return $file;
}
- \header( 'Content-Type: ' . $cached['mime_type'] );
- \header( 'Content-Length: ' . \filesize( $cached['path'] ) );
+ $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' );
- \readfile( $cached['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
+ \readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
exit;
}
@@ -159,15 +189,11 @@ public static function serve( $user_id, $year, $color_overrides = array() ) {
* @param int $year The year.
* @param array $color_overrides Optional bg/fg hex overrides (without #).
*
- * @return array|\WP_Error { path, mime_type } or error.
+ * @return string|\WP_Error Cached file path or error.
*/
public static function generate( $user_id, $year, $color_overrides = array() ) {
- if ( ! \function_exists( 'imagecreatetruecolor' ) ) {
- return new \WP_Error(
- 'gd_not_available',
- \__( 'GD library is not available.', 'activitypub' ),
- array( 'status' => 501 )
- );
+ 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 );
@@ -177,11 +203,7 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
}
if ( ! $summary || empty( $summary['posts_count'] ) ) {
- return new \WP_Error(
- 'no_stats',
- \__( 'No statistics available for this period.', 'activitypub' ),
- array( 'status' => 404 )
- );
+ return new \WP_Error( 'no_stats', \__( 'No statistics available for this period.', 'activitypub' ), array( 'status' => 404 ) );
}
$actor = Actors::get_by_id( $user_id );
@@ -207,116 +229,36 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
return $tmp_file;
}
- $cache_key = self::get_cache_key( $user_id, $year, $color_overrides );
- $result = self::optimize_and_store( $tmp_file, $cache_key );
-
- \wp_delete_file( $tmp_file );
-
- return $result;
- }
+ // Use the base class storage paths and optimization.
+ $paths = static::get_storage_paths( $user_id );
- /**
- * Build a cache key from the image parameters.
- *
- * @param int $user_id The user ID.
- * @param int $year The year.
- * @param array $color_overrides The color overrides.
- *
- * @return array Cache key with dir, base, hash.
- */
- private static function get_cache_key( $user_id, $year, $color_overrides ) {
- $upload_dir = \wp_upload_dir();
- $hash = \md5( \wp_json_encode( \array_filter( $color_overrides ) ) );
-
- return array(
- 'dir' => $upload_dir['basedir'] . self::BASE_DIR . $user_id,
- 'base' => \sprintf( 'stats-%d-%s', $year, $hash ),
- );
- }
-
- /**
- * Look for a cached image.
- *
- * @param array $cache_key The cache key.
- *
- * @return array|false { path, mime_type } or false if not cached.
- */
- private static function get_cached( $cache_key ) {
- $extensions = array(
- 'webp' => 'image/webp',
- 'png' => 'image/png',
- );
-
- foreach ( $extensions as $ext => $mime ) {
- $path = $cache_key['dir'] . '/' . $cache_key['base'] . '.' . $ext;
- if ( \file_exists( $path ) ) {
- return array(
- 'path' => $path,
- 'mime_type' => $mime,
- );
- }
+ 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 ) );
}
- return false;
- }
-
- /**
- * Optimize the image via WP_Image_Editor and save to cache.
- *
- * @param string $tmp_file Path to the temporary PNG.
- * @param array $cache_key The cache key.
- *
- * @return array|\WP_Error { path, mime_type } or error.
- */
- private static function optimize_and_store( $tmp_file, $cache_key ) {
- if ( ! \wp_mkdir_p( $cache_key['dir'] ) ) {
- return new \WP_Error(
- 'cache_dir_failed',
- \__( 'Failed to create cache directory.', 'activitypub' ),
- array( 'status' => 500 )
- );
- }
+ // Move to cache dir with a descriptive name, then optimize (WebP conversion).
+ $hash = self::get_hash( $color_overrides );
+ $dest_name = \sprintf( 'stats-%d-%s.png', $year, $hash );
+ $dest_path = $paths['basedir'] . '/' . $dest_name;
- $editor = \wp_get_image_editor( $tmp_file );
- $mime_type = 'image/png';
- $ext = 'png';
+ static::get_filesystem()->move( $tmp_file, $dest_path, true );
- if ( ! \is_wp_error( $editor ) && $editor->supports_mime_type( 'image/webp' ) ) {
- $mime_type = 'image/webp';
- $ext = 'webp';
- }
+ // Optimize via WP_Image_Editor (handles WebP conversion).
+ $optimized = static::optimize_image( $dest_path, self::WIDTH );
- $dest_path = $cache_key['dir'] . '/' . $cache_key['base'] . '.' . $ext;
-
- if ( ! \is_wp_error( $editor ) ) {
- $result = $editor->save( $dest_path, $mime_type );
-
- if ( ! \is_wp_error( $result ) ) {
- return array(
- 'path' => $result['path'],
- 'mime_type' => $mime_type,
- );
- }
- }
-
- // Fallback: copy the PNG directly.
- \copy( $tmp_file, $dest_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy
- return array(
- 'path' => $dest_path,
- 'mime_type' => 'image/png',
- );
+ return $optimized;
}
/**
- * Convert a filesystem path to a public URL.
+ * Generate a hash for color overrides.
*
- * @param string $path The filesystem path.
+ * @param array $color_overrides The color overrides.
*
- * @return string The public URL.
+ * @return string The hash string.
*/
- private static function path_to_url( $path ) {
- $upload_dir = \wp_upload_dir();
- return \str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $path );
+ private static function get_hash( $color_overrides ) {
+ return \md5( \wp_json_encode( \array_filter( $color_overrides ) ) );
}
/**
@@ -337,11 +279,7 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
$image = \imagecreatetruecolor( $width, $height );
if ( ! $image ) {
- return new \WP_Error(
- 'image_create_failed',
- \__( 'Failed to create image.', 'activitypub' ),
- array( 'status' => 500 )
- );
+ return new \WP_Error( 'image_create_failed', \__( 'Failed to create image.', 'activitypub' ), array( 'status' => 500 ) );
}
\imageantialias( $image, true );
@@ -362,19 +300,17 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
$total_engagement += $summary[ $slug . '_count' ] ?? 0;
}
- $followers_end = $summary['followers_end'] ?? 0;
-
// Title.
$title = \sprintf(
/* translators: %d: The year */
\__( 'Fediverse Stats %d', 'activitypub' ),
$year
);
- self::draw_text_centered( $image, $title, 100, 36, $fg, $font );
+ self::draw_text( $image, $title, null, 100, 36, $fg, $font );
// Actor webfinger.
if ( $actor_webfinger ) {
- self::draw_text_centered( $image, $actor_webfinger, 150, 20, $muted, $font );
+ self::draw_text( $image, $actor_webfinger, null, 150, 20, $muted, $font );
}
// Three big stats in a row.
@@ -388,7 +324,7 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
'label' => \__( 'Engagements', 'activitypub' ),
),
array(
- 'value' => \number_format_i18n( $followers_end ),
+ 'value' => \number_format_i18n( $summary['followers_end'] ?? 0 ),
'label' => \__( 'Followers', 'activitypub' ),
),
);
@@ -397,8 +333,8 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
foreach ( $stats as $i => $stat ) {
$center_x = (int) ( $col_width * $i + $col_width / 2 );
- self::draw_text_at( $image, $stat['value'], $center_x, 300, 56, $fg, $font );
- self::draw_text_at( $image, $stat['label'], $center_x, 355, 18, $muted, $font );
+ 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.
@@ -409,11 +345,11 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
\__( '%s followers this year', 'activitypub' ),
$change_sign . \number_format_i18n( $followers_net )
);
- self::draw_text_centered( $image, $growth_text, 450, 20, $muted, $font );
+ self::draw_text( $image, $growth_text, null, 450, 20, $muted, $font );
// Branding.
$branding = $site_name . ' - ' . \__( 'Powered by ActivityPub', 'activitypub' );
- self::draw_text_centered( $image, $branding, $height - 40, 14, $muted, $font );
+ self::draw_text( $image, $branding, null, $height - 40, 14, $muted, $font );
// Save to temp file.
$tmp_file = \wp_tempnam( 'activitypub-stats-' );
@@ -427,6 +363,39 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
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.
*
@@ -548,9 +517,8 @@ private static function resolve_style_color( $value, $palette ) {
}
if ( \preg_match( '/--color--([a-z0-9-]+)/', $value, $matches ) ) {
- $slug = $matches[1];
- if ( ! empty( $palette[ $slug ] ) ) {
- return self::parse_hex( $palette[ $slug ] );
+ if ( ! empty( $palette[ $matches[1] ] ) ) {
+ return self::parse_hex( $palette[ $matches[1] ] );
}
}
@@ -575,11 +543,9 @@ private static function parse_hex( $hex ) {
return false;
}
- return array(
- \hexdec( \substr( $hex, 0, 2 ) ),
- \hexdec( \substr( $hex, 2, 2 ) ),
- \hexdec( \substr( $hex, 4, 2 ) ),
- );
+ $result = \sscanf( $hex, '%02x%02x%02x' );
+
+ return ( 3 === \count( $result ) ) ? $result : false;
}
/**
@@ -590,10 +556,8 @@ private static function parse_hex( $hex ) {
private static function resolve_font() {
$body_slug = '';
$styles = \wp_get_global_styles( array( 'typography' ) );
- if ( ! empty( $styles['fontFamily'] ) ) {
- if ( \preg_match( '/--font-family--([a-z0-9-]+)/', $styles['fontFamily'], $matches ) ) {
- $body_slug = $matches[1];
- }
+ if ( ! empty( $styles['fontFamily'] ) && \preg_match( '/--font-family--([a-z0-9-]+)/', $styles['fontFamily'], $matches ) ) {
+ $body_slug = $matches[1];
}
$settings = \wp_get_global_settings();
@@ -605,40 +569,81 @@ private static function resolve_font() {
}
}
+ // Sort so the body font family is tried first.
if ( $body_slug ) {
\usort(
$all_families,
function ( $a, $b ) use ( $body_slug ) {
- $a_match = ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1;
- $b_match = ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1;
- return $a_match - $b_match;
+ return ( ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1 ) - ( ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1 );
}
);
}
- foreach ( $all_families as $family ) {
- if ( empty( $family['fontFace'] ) ) {
+ $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;
+ }
+
+ // Fall back to bundled WordPress theme fonts.
+ $fallbacks = array(
+ ABSPATH . 'wp-content/themes/twentytwentytwo/assets/fonts/dm-sans/DMSans-Regular.ttf',
+ ABSPATH . 'wp-content/themes/twentytwentythree/assets/fonts/dm-sans/DMSans-Regular.ttf',
+ );
+
+ foreach ( $fallbacks as $path ) {
+ if ( \file_exists( $path ) ) {
+ return $path;
+ }
+ }
+
+ 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 ) {
+ 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;
}
- foreach ( $family['fontFace'] as $face ) {
- $src = \is_array( $face['src'] ) ? $face['src'][0] : $face['src'];
- if ( ! \preg_match( '/\.(ttf|otf)$/i', $src ) ) {
- continue;
- }
-
- if ( 0 === \strpos( $src, 'file:./' ) ) {
- $src = \get_theme_file_path( \substr( $src, 7 ) );
- }
+ if ( 0 === \strpos( $src, 'file:./' ) ) {
+ $src = \get_theme_file_path( \substr( $src, 7 ) );
+ }
- if ( \file_exists( $src ) ) {
- return $src;
- }
+ if ( \file_exists( $src ) ) {
+ return $src;
}
}
}
- // Try the Font Library (WP 6.5+).
+ 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',
@@ -668,66 +673,6 @@ function ( $a, $b ) use ( $body_slug ) {
}
}
- $fallbacks = array(
- ABSPATH . 'wp-content/themes/twentytwentytwo/assets/fonts/dm-sans/DMSans-Regular.ttf',
- ABSPATH . 'wp-content/themes/twentytwentythree/assets/fonts/dm-sans/DMSans-Regular.ttf',
- );
-
- foreach ( $fallbacks as $path ) {
- if ( \file_exists( $path ) ) {
- return $path;
- }
- }
-
return false;
}
-
- /**
- * Draw centered text on the image.
- *
- * @param resource $image The image resource.
- * @param string $text The text to draw.
- * @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_centered( $image, $text, $y, $size, $color, $font = false ) {
- if ( $font && \function_exists( 'imagefttext' ) ) {
- $bbox = \imageftbbox( $size, 0, $font, $text );
- $text_width = $bbox[2] - $bbox[0];
- $x = (int) ( ( self::WIDTH - $text_width ) / 2 );
- \imagefttext( $image, $size, 0, $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 );
- $x = (int) ( ( self::WIDTH - $text_width ) / 2 );
- \imagestring( $image, $builtin_size, $x, $y, $text, $color );
- }
- }
-
- /**
- * Draw text centered at a specific x position.
- *
- * @param resource $image The image resource.
- * @param string $text The text to draw.
- * @param int $x The center x position.
- * @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_at( $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];
- \imagefttext( $image, $size, 0, (int) ( $x - $text_width / 2 ), $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 );
- \imagestring( $image, $builtin_size, (int) ( $x - $text_width / 2 ), $y, $text, $color );
- }
- }
}
From f163d90734e7f63f84f42463ab9d2d3db5894196 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 14:48:31 +0200
Subject: [PATCH 16/32] Replace hardcoded theme font paths with dynamic glob
search
---
includes/cache/class-stats-image.php | 12 ------------
1 file changed, 12 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 87aaf1d878..81ab7c2845 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -591,18 +591,6 @@ function ( $a, $b ) use ( $body_slug ) {
return $font;
}
- // Fall back to bundled WordPress theme fonts.
- $fallbacks = array(
- ABSPATH . 'wp-content/themes/twentytwentytwo/assets/fonts/dm-sans/DMSans-Regular.ttf',
- ABSPATH . 'wp-content/themes/twentytwentythree/assets/fonts/dm-sans/DMSans-Regular.ttf',
- );
-
- foreach ( $fallbacks as $path ) {
- if ( \file_exists( $path ) ) {
- return $path;
- }
- }
-
return false;
}
From 0b934e06a3e08d100999fcbcc5b4b4253f99aae8 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 14:56:13 +0200
Subject: [PATCH 17/32] Address Copilot review feedback
- Include theme stylesheet and version in cache hash so images are
regenerated on theme switch.
- Append color override params to REST URL when caching is disabled.
- Recursively search inner blocks for stats block (supports Group,
Columns, etc.).
- Handle wp_tempnam and imagepng failures with proper WP_Error.
- Restore original permalink_structure in test instead of hardcoding.
---
includes/cache/class-stats-image.php | 30 ++++++++++++++--
includes/class-blocks.php | 35 +++++++++++++++----
.../tests/includes/class-test-blocks.php | 4 +--
3 files changed, 58 insertions(+), 11 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 81ab7c2845..3e270e317c 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -97,7 +97,11 @@ public static function get_url( $user_id, $year, $color_overrides = array() ) {
// 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 );
+ $url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
+ $args = \array_filter( $color_overrides );
+ if ( ! empty( $args ) ) {
+ $url = \add_query_arg( $args, $url );
+ }
/**
* Filters the stats image URL.
@@ -258,7 +262,17 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
* @return string The hash string.
*/
private static function get_hash( $color_overrides ) {
- return \md5( \wp_json_encode( \array_filter( $color_overrides ) ) );
+ /*
+ * Include the theme stylesheet and version in the hash so cached
+ * images are regenerated when the theme (and its colors/fonts) changes.
+ */
+ $parts = array(
+ \array_filter( $color_overrides ),
+ \get_stylesheet(),
+ \wp_get_theme()->get( 'Version' ),
+ );
+
+ return \md5( \wp_json_encode( $parts ) );
}
/**
@@ -353,13 +367,23 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
// Save to temp file.
$tmp_file = \wp_tempnam( 'activitypub-stats-' );
- \imagepng( $image, $tmp_file );
+
+ 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;
}
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 39fb09b494..cc07dc4a9b 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -1062,13 +1062,10 @@ public static function add_stats_image_attachment( $attachments, $post ) {
* extracted from the post. It is always appended so that the share-pic is
* included in the federated activity regardless of the attachment cap.
*/
- $blocks = \parse_blocks( $post->post_content );
-
- foreach ( $blocks as $block ) {
- if ( 'activitypub/stats' !== $block['blockName'] ) {
- continue;
- }
+ $blocks = \parse_blocks( $post->post_content );
+ $stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
+ foreach ( $stats_blocks as $block ) {
$user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
if ( null === $user_id ) {
@@ -1100,6 +1097,32 @@ public static function add_stats_image_attachment( $attachments, $post ) {
return $attachments;
}
+ /**
+ * Recursively find blocks of a given type in a block tree.
+ *
+ * @since unreleased
+ *
+ * @param array $blocks The parsed blocks.
+ * @param string $block_name The block name to search for.
+ *
+ * @return array The matching blocks.
+ */
+ private static function find_blocks_recursive( $blocks, $block_name ) {
+ $found = array();
+
+ foreach ( $blocks as $block ) {
+ if ( $block_name === $block['blockName'] ) {
+ $found[] = $block;
+ }
+
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $found = \array_merge( $found, self::find_blocks_recursive( $block['innerBlocks'], $block_name ) );
+ }
+ }
+
+ return $found;
+ }
+
/**
* Transform Embed blocks to block level link.
*
diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php
index f792f62787..61ae44b320 100644
--- a/tests/phpunit/tests/includes/class-test-blocks.php
+++ b/tests/phpunit/tests/includes/class-test-blocks.php
@@ -1210,6 +1210,7 @@ public function test_get_stats_image_url() {
* @covers \Activitypub\Cache\Stats_Image::get_url
*/
public function test_get_stats_image_url_plain_permalinks() {
+ $original = \get_option( 'permalink_structure' );
\update_option( 'permalink_structure', '' );
$url = \Activitypub\Cache\Stats_Image::get_url( 1, 2024 );
@@ -1222,7 +1223,6 @@ public function test_get_stats_image_url_plain_permalinks() {
$this->assertStringContainsString( 'stats', $url );
$this->assertStringContainsString( '2024', $url );
- // Restore.
- \update_option( 'permalink_structure', '/%postname%/' );
+ \update_option( 'permalink_structure', $original );
}
}
From 6b1cce3ff6c4a6811207764d787dee3c4e7e200d Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 15:03:51 +0200
Subject: [PATCH 18/32] Address security audit findings
- Remove color override params (bg/fg) from REST endpoints to prevent
cache-flooding DoS via unlimited color combinations.
- Validate font paths against the theme directory using realpath() to
prevent arbitrary file reads via crafted theme.json.
- Add year bounds (2000 to current year) to REST schema.
- Add user_id validation against existing users.
- Add X-Content-Type-Options: nosniff header to image responses.
- Simplify color resolution to use theme colors only.
- Cache key now based on theme identity only (no color variants).
---
includes/cache/class-stats-image.php | 92 +++++++------------
includes/class-blocks.php | 2 +-
.../rest/class-stats-image-controller.php | 56 +++++------
.../class-test-stats-image-controller.php | 16 ----
4 files changed, 63 insertions(+), 103 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 3e270e317c..e555c524a5 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -84,24 +84,19 @@ public static function is_available() {
/**
* Get the public URL for a stats image, generating it if needed.
*
- * @param int $user_id The user ID.
- * @param int $year The year.
- * @param array $color_overrides Optional bg/fg hex overrides (without #).
+ * @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, $color_overrides = array() ) {
+ 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 );
- $args = \array_filter( $color_overrides );
- if ( ! empty( $args ) ) {
- $url = \add_query_arg( $args, $url );
- }
+ $url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
/**
* Filters the stats image URL.
@@ -117,7 +112,7 @@ public static function get_url( $user_id, $year, $color_overrides = array() ) {
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
}
- $hash = self::get_hash( $color_overrides );
+ $hash = self::get_hash();
$paths = static::get_storage_paths( $user_id );
// Check for cached file using the base class glob pattern.
@@ -132,7 +127,7 @@ public static function get_url( $user_id, $year, $color_overrides = array() ) {
}
// Generate the image.
- $result = self::generate( $user_id, $year, $color_overrides );
+ $result = self::generate( $user_id, $year );
if ( \is_wp_error( $result ) ) {
return $result;
@@ -149,18 +144,17 @@ public static function get_url( $user_id, $year, $color_overrides = array() ) {
*
* Outputs headers and image data, then exits.
*
- * @param int $user_id The user ID.
- * @param int $year The year.
- * @param array $color_overrides Optional bg/fg hex overrides (without #).
+ * @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, $color_overrides = array() ) {
+ 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( $color_overrides );
+ $hash = self::get_hash();
$paths = static::get_storage_paths( $user_id );
// Check for cached file.
@@ -169,7 +163,7 @@ public static function serve( $user_id, $year, $color_overrides = array() ) {
$file = ( ! empty( $matches ) && \is_file( $matches[0] ) ) ? $matches[0] : null;
if ( ! $file ) {
- $file = self::generate( $user_id, $year, $color_overrides );
+ $file = self::generate( $user_id, $year );
}
if ( \is_wp_error( $file ) ) {
@@ -181,6 +175,7 @@ public static function serve( $user_id, $year, $color_overrides = array() ) {
\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;
@@ -189,13 +184,12 @@ public static function serve( $user_id, $year, $color_overrides = array() ) {
/**
* Generate the stats image and save to cache.
*
- * @param int $user_id The user ID.
- * @param int $year The year.
- * @param array $color_overrides Optional bg/fg hex overrides (without #).
+ * @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, $color_overrides = array() ) {
+ 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 ) );
}
@@ -227,7 +221,7 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
- $tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year, $color_overrides );
+ $tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year );
if ( \is_wp_error( $tmp_file ) ) {
return $tmp_file;
@@ -242,7 +236,7 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
}
// Move to cache dir with a descriptive name, then optimize (WebP conversion).
- $hash = self::get_hash( $color_overrides );
+ $hash = self::get_hash();
$dest_name = \sprintf( 'stats-%d-%s.png', $year, $hash );
$dest_path = $paths['basedir'] . '/' . $dest_name;
@@ -255,19 +249,16 @@ public static function generate( $user_id, $year, $color_overrides = array() ) {
}
/**
- * Generate a hash for color overrides.
+ /**
+ * Generate a hash based on the active theme.
*
- * @param array $color_overrides The color overrides.
+ * Includes the theme stylesheet and version so cached images are
+ * regenerated when the theme (and its colors/fonts) changes.
*
* @return string The hash string.
*/
- private static function get_hash( $color_overrides ) {
- /*
- * Include the theme stylesheet and version in the hash so cached
- * images are regenerated when the theme (and its colors/fonts) changes.
- */
+ private static function get_hash() {
$parts = array(
- \array_filter( $color_overrides ),
\get_stylesheet(),
\wp_get_theme()->get( 'Version' ),
);
@@ -282,11 +273,9 @@ private static function get_hash( $color_overrides ) {
* @param string $actor_webfinger The actor webfinger identifier.
* @param string $site_name The site name.
* @param int $year The year.
- * @param array $color_overrides Optional bg/fg hex color overrides.
- *
* @return string|\WP_Error Path to temporary PNG file or error.
*/
- private static function render( $summary, $actor_webfinger, $site_name, $year, $color_overrides = array() ) {
+ private static function render( $summary, $actor_webfinger, $site_name, $year ) {
$width = self::WIDTH;
$height = self::HEIGHT;
@@ -298,7 +287,7 @@ private static function render( $summary, $actor_webfinger, $site_name, $year, $
\imageantialias( $image, true );
- $colors = self::resolve_colors( $color_overrides );
+ $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] );
@@ -423,32 +412,12 @@ private static function draw_text( $image, $text, $x, $y, $size, $color, $font =
/**
* Resolve colors from theme Global Styles or overrides.
*
- * @param array $overrides Optional bg/fg hex color overrides.
- *
* @return array Associative array with 'bg', 'fg', and 'muted' RGB arrays.
*/
- private static function resolve_colors( $overrides = array() ) {
+ private static function resolve_colors() {
$bg_rgb = array( 255, 255, 255 );
$fg_rgb = array( 17, 17, 17 );
- if ( ! empty( $overrides['bg'] ) ) {
- $parsed = self::parse_hex( $overrides['bg'] );
- if ( $parsed ) {
- $bg_rgb = $parsed;
- }
- }
-
- if ( ! empty( $overrides['fg'] ) ) {
- $parsed = self::parse_hex( $overrides['fg'] );
- if ( $parsed ) {
- $fg_rgb = $parsed;
- }
- }
-
- if ( ! empty( $overrides['bg'] ) && ! empty( $overrides['fg'] ) ) {
- return self::build_color_set( $bg_rgb, $fg_rgb );
- }
-
$palette = array();
$settings = \wp_get_global_settings();
if ( ! empty( $settings['color']['palette'] ) ) {
@@ -626,6 +595,8 @@ function ( $a, $b ) use ( $body_slug ) {
* @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;
@@ -637,13 +608,18 @@ private static function find_ttf_in_families( $families ) {
continue;
}
+ // Resolve theme-relative paths.
if ( 0 === \strpos( $src, 'file:./' ) ) {
$src = \get_theme_file_path( \substr( $src, 7 ) );
}
- if ( \file_exists( $src ) ) {
- return $src;
+ // 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;
}
}
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index cc07dc4a9b..2c5c502ac7 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -1062,7 +1062,7 @@ public static function add_stats_image_attachment( $attachments, $post ) {
* extracted from the post. It is always appended so that the share-pic is
* included in the federated activity regardless of the attachment cap.
*/
- $blocks = \parse_blocks( $post->post_content );
+ $blocks = \parse_blocks( $post->post_content );
$stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
foreach ( $stats_blocks as $block ) {
diff --git a/includes/rest/class-stats-image-controller.php b/includes/rest/class-stats-image-controller.php
index 51de4cc197..c49476fdcb 100644
--- a/includes/rest/class-stats-image-controller.php
+++ b/includes/rest/class-stats-image-controller.php
@@ -45,26 +45,42 @@ private function get_common_args() {
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
+ 'validate_callback' => array( $this, 'validate_user_id' ),
),
'year' => array(
'description' => \__( 'The year to display stats for.', 'activitypub' ),
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
- ),
- 'bg' => array(
- 'description' => \__( 'Background color as hex (without #).', 'activitypub' ),
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_hex_color_no_hash',
- ),
- 'fg' => array(
- 'description' => \__( 'Text color as hex (without #).', 'activitypub' ),
- 'type' => 'string',
- 'sanitize_callback' => 'sanitize_hex_color_no_hash',
+ 'minimum' => 2000,
+ 'maximum' => (int) \gmdate( 'Y' ),
),
);
}
+ /**
+ * Validate the user_id parameter.
+ *
+ * @param mixed $value The parameter value.
+ *
+ * @return true|\WP_Error True if valid, error otherwise.
+ */
+ public function validate_user_id( $value ) {
+ $user_id = (int) $value;
+
+ // Blog and Application user IDs are always valid.
+ if ( 0 === $user_id || \Activitypub\Collection\Actors::APPLICATION_USER_ID === $user_id ) {
+ return true;
+ }
+
+ // Check that the user exists.
+ if ( ! \get_user_by( 'id', $user_id ) ) {
+ return new \WP_Error( 'invalid_user', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 404 ) );
+ }
+
+ return true;
+ }
+
/**
* Register routes.
*/
@@ -110,8 +126,7 @@ public function register_routes() {
public function get_item( $request ) {
return Stats_Image::serve(
(int) $request->get_param( 'user_id' ),
- (int) $request->get_param( 'year' ),
- $this->get_color_overrides( $request )
+ (int) $request->get_param( 'year' )
);
}
@@ -129,8 +144,7 @@ public function get_item( $request ) {
public function get_url( $request ) {
$url = Stats_Image::get_url(
(int) $request->get_param( 'user_id' ),
- (int) $request->get_param( 'year' ),
- $this->get_color_overrides( $request )
+ (int) $request->get_param( 'year' )
);
if ( \is_wp_error( $url ) ) {
@@ -139,18 +153,4 @@ public function get_url( $request ) {
return \rest_ensure_response( array( 'url' => $url ) );
}
-
- /**
- * Extract color overrides from the request.
- *
- * @param \WP_REST_Request $request The request object.
- *
- * @return array The color overrides.
- */
- private function get_color_overrides( $request ) {
- return array(
- 'bg' => $request->get_param( 'bg' ),
- 'fg' => $request->get_param( 'fg' ),
- );
- }
}
diff --git a/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php b/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
index be77ff7719..f738cb2263 100644
--- a/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
+++ b/tests/phpunit/tests/includes/rest/class-test-stats-image-controller.php
@@ -159,22 +159,6 @@ public function test_endpoint_is_public() {
$this->assertEquals( 404, $response->get_status() );
}
- /**
- * Test route accepts color parameters.
- *
- * @covers ::register_routes
- */
- public function test_route_accepts_color_params() {
- $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . self::$user_id . '/1999' );
- $request->set_param( 'bg', 'ff0000' );
- $request->set_param( 'fg', '00ff00' );
-
- $response = \rest_get_server()->dispatch( $request );
-
- // Should get 404 (no stats), not 400 (bad params).
- $this->assertEquals( 404, $response->get_status() );
- }
-
/**
* Test invalid year format.
*
From 7661edfb92f0c2a26c6df6c53ec9b3289b990547 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 15:11:49 +0200
Subject: [PATCH 19/32] Replace inline namespace references with use imports
---
includes/cache/class-stats-image.php | 6 ++++--
includes/rest/class-stats-image-controller.php | 3 ++-
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index e555c524a5..32b6f4ee56 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -9,6 +9,8 @@
namespace Activitypub\Cache;
use Activitypub\Collection\Actors;
+use Activitypub\Model\Application;
+use Activitypub\Model\Blog;
use Activitypub\Statistics;
/**
@@ -208,9 +210,9 @@ public static function generate( $user_id, $year ) {
if ( \is_wp_error( $actor ) ) {
if ( Actors::BLOG_USER_ID === $user_id ) {
- $actor = new \Activitypub\Model\Blog();
+ $actor = new Blog();
} elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
- $actor = new \Activitypub\Model\Application();
+ $actor = new Application();
}
}
diff --git a/includes/rest/class-stats-image-controller.php b/includes/rest/class-stats-image-controller.php
index c49476fdcb..2f0d97af99 100644
--- a/includes/rest/class-stats-image-controller.php
+++ b/includes/rest/class-stats-image-controller.php
@@ -9,6 +9,7 @@
namespace Activitypub\Rest;
use Activitypub\Cache\Stats_Image;
+use Activitypub\Collection\Actors;
/**
* REST controller that serves stats share images.
@@ -69,7 +70,7 @@ public function validate_user_id( $value ) {
$user_id = (int) $value;
// Blog and Application user IDs are always valid.
- if ( 0 === $user_id || \Activitypub\Collection\Actors::APPLICATION_USER_ID === $user_id ) {
+ if ( 0 === $user_id || Actors::APPLICATION_USER_ID === $user_id ) {
return true;
}
From 45cfd7fed30b8cd8115d6b7089d501c68e9b6cc7 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 15:18:42 +0200
Subject: [PATCH 20/32] Use WP_Style_Engine for border style sanitization
Replace manual regex/allowlist border validation with
wp_style_engine_get_styles() which handles sanitization,
camelCase-to-CSS conversion, and per-corner radius natively.
---
build/stats/render.php | 56 ++++++++++++++----------------------------
src/stats/render.php | 56 ++++++++++++++----------------------------
2 files changed, 36 insertions(+), 76 deletions(-)
diff --git a/build/stats/render.php b/build/stats/render.php
index 66cba63dcf..51a144b7b5 100644
--- a/build/stats/render.php
+++ b/build/stats/render.php
@@ -87,48 +87,29 @@
);
/*
- * Build border styles manually since serialization is skipped.
- * Each value is sanitized individually to prevent CSS injection from
- * imported or migrated post content.
+ * Build border styles using WP_Style_Engine for sanitization.
+ * Border serialization is skipped in block.json to avoid double
+ * rendering in the editor, so we apply it here manually.
*/
-$border = $attributes['style']['border'] ?? array();
-$border_styles = array();
+$border_result = \wp_style_engine_get_styles( array( 'border' => $attributes['style']['border'] ?? array() ) );
+$extra_styles = $border_result['css'] ?? '';
-$border_color = '';
-if ( ! empty( $border['color'] ) && \preg_match( '/^(#[0-9a-f]{3,8}|var\(--[\w-]+\))$/i', $border['color'] ) ) {
- $border_color = $border['color'];
- $border_styles[] = 'border-color:' . $border['color'];
-} elseif ( ! empty( $attributes['borderColor'] ) && \preg_match( '/^[a-z0-9-]+$/i', $attributes['borderColor'] ) ) {
- $border_color = 'var(--wp--preset--color--' . $attributes['borderColor'] . ')';
- $border_styles[] = 'border-color:' . $border_color;
-}
-
-if ( ! empty( $border['width'] ) && \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['width'] ) ) {
- $border_styles[] = 'border-width:' . $border['width'];
+// Handle preset border color slug (not part of style.border).
+if ( ! empty( $attributes['borderColor'] ) ) {
+ $preset_color = 'var(--wp--preset--color--' . \sanitize_key( $attributes['borderColor'] ) . ')';
+ $extra_styles = 'border-color:' . $preset_color . ';' . $extra_styles;
}
-$allowed_styles = array( 'none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset' );
-if ( ! empty( $border['style'] ) && \in_array( $border['style'], $allowed_styles, true ) ) {
- $border_styles[] = 'border-style:' . $border['style'];
-}
-
-if ( ! empty( $border['radius'] ) ) {
- if ( \is_array( $border['radius'] ) ) {
- foreach ( array( 'topLeft', 'topRight', 'bottomRight', 'bottomLeft' ) as $corner ) {
- $value = $border['radius'][ $corner ] ?? '0';
- if ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $value ) ) {
- $css_corner = \preg_replace( '/([A-Z])/', '-$1', $corner );
- $border_styles[] = 'border-' . \strtolower( $css_corner ) . '-radius:' . $value;
- }
- }
- } elseif ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['radius'] ) ) {
- $border_styles[] = 'border-radius:' . $border['radius'];
- }
+// Resolve the border color for inner elements via CSS variable.
+$border_color = '';
+if ( ! empty( $attributes['style']['border']['color'] ) ) {
+ $border_color = $attributes['style']['border']['color'];
+} elseif ( ! empty( $attributes['borderColor'] ) ) {
+ $border_color = 'var(--wp--preset--color--' . \sanitize_key( $attributes['borderColor'] ) . ')';
}
-// Pass border color to inner elements via CSS variable.
if ( $border_color ) {
- $border_styles[] = '--activitypub-stats--border-color:' . $border_color;
+ $extra_styles .= '--activitypub-stats--border-color:' . \esc_attr( $border_color ) . ';';
}
$wrapper_attrs = array(
@@ -136,13 +117,12 @@
'class' => 'activitypub-stats',
);
-$extra_styles = ! empty( $border_styles ) ? \implode( ';', $border_styles ) : '';
$wrapper_html = \get_block_wrapper_attributes( $wrapper_attrs );
-// Merge our border styles into the existing style attribute.
+// Merge border styles into the existing style attribute.
if ( $extra_styles ) {
if ( \str_contains( $wrapper_html, 'style="' ) ) {
- $wrapper_html = \str_replace( 'style="', 'style="' . \esc_attr( $extra_styles ) . ';', $wrapper_html );
+ $wrapper_html = \str_replace( 'style="', 'style="' . \esc_attr( $extra_styles ), $wrapper_html );
} else {
$wrapper_html .= ' style="' . \esc_attr( $extra_styles ) . '"';
}
diff --git a/src/stats/render.php b/src/stats/render.php
index 66cba63dcf..51a144b7b5 100644
--- a/src/stats/render.php
+++ b/src/stats/render.php
@@ -87,48 +87,29 @@
);
/*
- * Build border styles manually since serialization is skipped.
- * Each value is sanitized individually to prevent CSS injection from
- * imported or migrated post content.
+ * Build border styles using WP_Style_Engine for sanitization.
+ * Border serialization is skipped in block.json to avoid double
+ * rendering in the editor, so we apply it here manually.
*/
-$border = $attributes['style']['border'] ?? array();
-$border_styles = array();
+$border_result = \wp_style_engine_get_styles( array( 'border' => $attributes['style']['border'] ?? array() ) );
+$extra_styles = $border_result['css'] ?? '';
-$border_color = '';
-if ( ! empty( $border['color'] ) && \preg_match( '/^(#[0-9a-f]{3,8}|var\(--[\w-]+\))$/i', $border['color'] ) ) {
- $border_color = $border['color'];
- $border_styles[] = 'border-color:' . $border['color'];
-} elseif ( ! empty( $attributes['borderColor'] ) && \preg_match( '/^[a-z0-9-]+$/i', $attributes['borderColor'] ) ) {
- $border_color = 'var(--wp--preset--color--' . $attributes['borderColor'] . ')';
- $border_styles[] = 'border-color:' . $border_color;
-}
-
-if ( ! empty( $border['width'] ) && \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['width'] ) ) {
- $border_styles[] = 'border-width:' . $border['width'];
+// Handle preset border color slug (not part of style.border).
+if ( ! empty( $attributes['borderColor'] ) ) {
+ $preset_color = 'var(--wp--preset--color--' . \sanitize_key( $attributes['borderColor'] ) . ')';
+ $extra_styles = 'border-color:' . $preset_color . ';' . $extra_styles;
}
-$allowed_styles = array( 'none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset' );
-if ( ! empty( $border['style'] ) && \in_array( $border['style'], $allowed_styles, true ) ) {
- $border_styles[] = 'border-style:' . $border['style'];
-}
-
-if ( ! empty( $border['radius'] ) ) {
- if ( \is_array( $border['radius'] ) ) {
- foreach ( array( 'topLeft', 'topRight', 'bottomRight', 'bottomLeft' ) as $corner ) {
- $value = $border['radius'][ $corner ] ?? '0';
- if ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $value ) ) {
- $css_corner = \preg_replace( '/([A-Z])/', '-$1', $corner );
- $border_styles[] = 'border-' . \strtolower( $css_corner ) . '-radius:' . $value;
- }
- }
- } elseif ( \preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $border['radius'] ) ) {
- $border_styles[] = 'border-radius:' . $border['radius'];
- }
+// Resolve the border color for inner elements via CSS variable.
+$border_color = '';
+if ( ! empty( $attributes['style']['border']['color'] ) ) {
+ $border_color = $attributes['style']['border']['color'];
+} elseif ( ! empty( $attributes['borderColor'] ) ) {
+ $border_color = 'var(--wp--preset--color--' . \sanitize_key( $attributes['borderColor'] ) . ')';
}
-// Pass border color to inner elements via CSS variable.
if ( $border_color ) {
- $border_styles[] = '--activitypub-stats--border-color:' . $border_color;
+ $extra_styles .= '--activitypub-stats--border-color:' . \esc_attr( $border_color ) . ';';
}
$wrapper_attrs = array(
@@ -136,13 +117,12 @@
'class' => 'activitypub-stats',
);
-$extra_styles = ! empty( $border_styles ) ? \implode( ';', $border_styles ) : '';
$wrapper_html = \get_block_wrapper_attributes( $wrapper_attrs );
-// Merge our border styles into the existing style attribute.
+// Merge border styles into the existing style attribute.
if ( $extra_styles ) {
if ( \str_contains( $wrapper_html, 'style="' ) ) {
- $wrapper_html = \str_replace( 'style="', 'style="' . \esc_attr( $extra_styles ) . ';', $wrapper_html );
+ $wrapper_html = \str_replace( 'style="', 'style="' . \esc_attr( $extra_styles ), $wrapper_html );
} else {
$wrapper_html .= ' style="' . \esc_attr( $extra_styles ) . '"';
}
From 8f8396c4f8f37a7e3dbd2b562179e0dbbbe8dfc6 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 15:24:56 +0200
Subject: [PATCH 21/32] Add shadow support to stats block
---
build/stats/block.json | 3 ++-
src/stats/block.json | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/build/stats/block.json b/build/stats/block.json
index 2cdd5bdbc3..880c33693c 100644
--- a/build/stats/block.json
+++ b/build/stats/block.json
@@ -45,7 +45,8 @@
"style": true,
"width": true,
"__experimentalSkipSerialization": true
- }
+ },
+ "shadow": true
},
"attributes": {
"selectedUser": {
diff --git a/src/stats/block.json b/src/stats/block.json
index 90b840d537..63a8a2f452 100644
--- a/src/stats/block.json
+++ b/src/stats/block.json
@@ -35,7 +35,8 @@
"style": true,
"width": true,
"__experimentalSkipSerialization": true
- }
+ },
+ "shadow": true
},
"attributes": {
"selectedUser": {
From d3e53fc659652f2478243b55039248e43b232d0c Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 1 Apr 2026 15:27:34 +0200
Subject: [PATCH 22/32] Fix double shadow in editor by resetting boxShadow on
wrapper
---
build/stats/index.asset.php | 2 +-
build/stats/index.js | 2 +-
src/stats/edit.js | 1 +
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/build/stats/index.asset.php b/build/stats/index.asset.php
index 52718efa25..16ab207378 100644
--- a/build/stats/index.asset.php
+++ b/build/stats/index.asset.php
@@ -1 +1 @@
- array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-server-side-render'), 'version' => '61794b4cb4a4be8a32fd');
+ array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-server-side-render'), 'version' => 'f8facb01a21031781a75');
diff --git a/build/stats/index.js b/build/stats/index.js
index f7b5f3c44f..2b874cbcbe 100644
--- a/build/stats/index.js
+++ b/build/stats/index.js
@@ -1 +1 @@
-(()=>{"use strict";var e,t={1868(e,t,i){const r=window.wp.blocks,s=window.wp.serverSideRender;var n=i.n(s);const a=window.wp.components,o=window.wp.blockEditor,l=window.wp.i18n,c=window.wp.element,u=window.wp.apiFetch;var d=i.n(u);const p=window.wp.data;const v=window.ReactJSXRuntime,b=(new Date).getFullYear();function h(){const e=[];for(let t=b;t>=b-5;t--)e.push({label:String(t),value:String(t)});return e}const g=JSON.parse('{"UU":"activitypub/stats"}');(0,r.registerBlockType)(g.UU,{edit:function({attributes:e,setAttributes:t}){const{selectedUser:i,year:r}=e,s=(0,o.useBlockProps)({style:{border:"none",borderRadius:void 0,padding:void 0,margin:void 0,background:void 0,backgroundColor:void 0,color:void 0}}),u=function({withInherit:e=!1}){const{enabled:t,namespace:i}=window._activityPubOptions||{},[r,s]=(0,c.useState)(!1),{fetchedUsers:n,isLoadingUsers:a}=(0,p.useSelect)(e=>{const{getUsers:i,getIsResolving:r}=e("core");return{fetchedUsers:t?.users?i({capabilities:"activitypub"}):null,isLoadingUsers:!!t?.users&&r("getUsers",[{capabilities:"activitypub"}])}},[t?.users]),o=(0,p.useSelect)(e=>n||a?null:e("core").getCurrentUser(),[n,a]);(0,c.useEffect)(()=>{n||a||!o||d()({path:`/${i}/actors/${o.id}`,method:"HEAD",headers:{Accept:"application/activity+json"},parse:!1}).then(()=>s(!0)).catch(()=>s(!1))},[n,a,o,i]);const u=(0,c.useMemo)(()=>n||(o&&r?[{id:o.id,name:o.name}]:[]),[n,o,r]);return(0,c.useMemo)(()=>{if(!u.length)return[];const i=[];return t?.blog&&n&&i.push({label:(0,l.__)("Blog","activitypub"),value:"blog"}),e&&t?.users&&n&&i.push({label:(0,l.__)("Dynamic User","activitypub"),value:"inherit"}),u.reduce((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e),i)},[u,t?.blog,t?.users,n,e])}({}),[g,w]=(0,c.useState)(!1);(0,c.useEffect)(()=>{!i&&u.length&&t({selectedUser:u[0].value})},[u]);const y=r||b-1,[f,_]=(0,c.useState)(""),m=(0,c.useCallback)(()=>{const e=function(e,t){const i=window._activityPubOptions?.statsImageUrlEndpoint||"";if(!i)return"";const r=e&&"blog"!==e?e:0;return i.replace("{user_id}",r).replace("{year}",t)}(i||"blog",y);e&&d()({url:e}).then(e=>_(e.url||"")).catch(()=>_(""))},[i,y]);return(0,c.useEffect)(()=>{m()},[m]),(0,v.jsxs)("div",{...s,children:[(0,v.jsxs)(o.InspectorControls,{children:[(0,v.jsxs)(a.PanelBody,{title:(0,l.__)("Settings","activitypub"),children:[u.length>1&&(0,v.jsx)(a.SelectControl,{label:(0,l.__)("Select User","activitypub"),value:i,options:u,onChange:e=>t({selectedUser:e})}),(0,v.jsx)(a.SelectControl,{label:(0,l.__)("Year","activitypub"),value:String(y),options:h(),onChange:e=>t({year:parseInt(e,10)})})]}),f&&(0,v.jsxs)(a.PanelBody,{title:(0,l.__)("Share Image","activitypub"),initialOpen:!1,children:[(0,v.jsx)("p",{className:"description",children:(0,l.__)("Use this URL to share your stats as an image on social media.","activitypub")}),(0,v.jsx)(a.TextControl,{__nextHasNoMarginBottom:!0,value:f,readOnly:!0,onClick:e=>e.target.select()}),(0,v.jsxs)("div",{style:{display:"flex",gap:"8px",alignItems:"center"},children:[(0,v.jsx)(a.Button,{variant:"secondary",onClick:()=>{navigator.clipboard.writeText(f).then(()=>{w(!0),setTimeout(()=>w(!1),2e3)})},children:g?(0,l.__)("Copied!","activitypub"):(0,l.__)("Copy URL","activitypub")}),(0,v.jsx)(a.ExternalLink,{href:f,children:(0,l.__)("Preview","activitypub")})]})]})]}),(0,v.jsx)(a.Disabled,{children:(0,v.jsx)(n(),{block:"activitypub/stats",attributes:{...e,year:y}})})]})}})}},i={};function r(e){var s=i[e];if(void 0!==s)return s.exports;var n=i[e]={exports:{}};return t[e](n,n.exports,r),n.exports}r.m=t,e=[],r.O=(t,i,s,n)=>{if(!i){var a=1/0;for(u=0;u=n)&&Object.keys(r.O).every(e=>r.O[e](i[l]))?i.splice(l--,1):(o=!1,n0&&e[u-1][2]>n;u--)e[u]=e[u-1];e[u]=[i,s,n]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var i in t)r.o(t,i)&&!r.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={85:0,721:0};r.O.j=t=>0===e[t];var t=(t,i)=>{var s,n,[a,o,l]=i,c=0;if(a.some(t=>0!==e[t])){for(s in o)r.o(o,s)&&(r.m[s]=o[s]);if(l)var u=l(r)}for(t&&t(i);cr(1868));s=r.O(s)})();
\ No newline at end of file
+(()=>{"use strict";var e,t={1868(e,t,i){const r=window.wp.blocks,s=window.wp.serverSideRender;var n=i.n(s);const a=window.wp.components,o=window.wp.blockEditor,l=window.wp.i18n,c=window.wp.element,u=window.wp.apiFetch;var d=i.n(u);const p=window.wp.data;const v=window.ReactJSXRuntime,b=(new Date).getFullYear();function h(){const e=[];for(let t=b;t>=b-5;t--)e.push({label:String(t),value:String(t)});return e}const g=JSON.parse('{"UU":"activitypub/stats"}');(0,r.registerBlockType)(g.UU,{edit:function({attributes:e,setAttributes:t}){const{selectedUser:i,year:r}=e,s=(0,o.useBlockProps)({style:{border:"none",borderRadius:void 0,boxShadow:void 0,padding:void 0,margin:void 0,background:void 0,backgroundColor:void 0,color:void 0}}),u=function({withInherit:e=!1}){const{enabled:t,namespace:i}=window._activityPubOptions||{},[r,s]=(0,c.useState)(!1),{fetchedUsers:n,isLoadingUsers:a}=(0,p.useSelect)(e=>{const{getUsers:i,getIsResolving:r}=e("core");return{fetchedUsers:t?.users?i({capabilities:"activitypub"}):null,isLoadingUsers:!!t?.users&&r("getUsers",[{capabilities:"activitypub"}])}},[t?.users]),o=(0,p.useSelect)(e=>n||a?null:e("core").getCurrentUser(),[n,a]);(0,c.useEffect)(()=>{n||a||!o||d()({path:`/${i}/actors/${o.id}`,method:"HEAD",headers:{Accept:"application/activity+json"},parse:!1}).then(()=>s(!0)).catch(()=>s(!1))},[n,a,o,i]);const u=(0,c.useMemo)(()=>n||(o&&r?[{id:o.id,name:o.name}]:[]),[n,o,r]);return(0,c.useMemo)(()=>{if(!u.length)return[];const i=[];return t?.blog&&n&&i.push({label:(0,l.__)("Blog","activitypub"),value:"blog"}),e&&t?.users&&n&&i.push({label:(0,l.__)("Dynamic User","activitypub"),value:"inherit"}),u.reduce((e,t)=>(e.push({label:t.name,value:`${t.id}`}),e),i)},[u,t?.blog,t?.users,n,e])}({}),[g,w]=(0,c.useState)(!1);(0,c.useEffect)(()=>{!i&&u.length&&t({selectedUser:u[0].value})},[u]);const y=r||b-1,[f,_]=(0,c.useState)(""),m=(0,c.useCallback)(()=>{const e=function(e,t){const i=window._activityPubOptions?.statsImageUrlEndpoint||"";if(!i)return"";const r=e&&"blog"!==e?e:0;return i.replace("{user_id}",r).replace("{year}",t)}(i||"blog",y);e&&d()({url:e}).then(e=>_(e.url||"")).catch(()=>_(""))},[i,y]);return(0,c.useEffect)(()=>{m()},[m]),(0,v.jsxs)("div",{...s,children:[(0,v.jsxs)(o.InspectorControls,{children:[(0,v.jsxs)(a.PanelBody,{title:(0,l.__)("Settings","activitypub"),children:[u.length>1&&(0,v.jsx)(a.SelectControl,{label:(0,l.__)("Select User","activitypub"),value:i,options:u,onChange:e=>t({selectedUser:e})}),(0,v.jsx)(a.SelectControl,{label:(0,l.__)("Year","activitypub"),value:String(y),options:h(),onChange:e=>t({year:parseInt(e,10)})})]}),f&&(0,v.jsxs)(a.PanelBody,{title:(0,l.__)("Share Image","activitypub"),initialOpen:!1,children:[(0,v.jsx)("p",{className:"description",children:(0,l.__)("Use this URL to share your stats as an image on social media.","activitypub")}),(0,v.jsx)(a.TextControl,{__nextHasNoMarginBottom:!0,value:f,readOnly:!0,onClick:e=>e.target.select()}),(0,v.jsxs)("div",{style:{display:"flex",gap:"8px",alignItems:"center"},children:[(0,v.jsx)(a.Button,{variant:"secondary",onClick:()=>{navigator.clipboard.writeText(f).then(()=>{w(!0),setTimeout(()=>w(!1),2e3)})},children:g?(0,l.__)("Copied!","activitypub"):(0,l.__)("Copy URL","activitypub")}),(0,v.jsx)(a.ExternalLink,{href:f,children:(0,l.__)("Preview","activitypub")})]})]})]}),(0,v.jsx)(a.Disabled,{children:(0,v.jsx)(n(),{block:"activitypub/stats",attributes:{...e,year:y}})})]})}})}},i={};function r(e){var s=i[e];if(void 0!==s)return s.exports;var n=i[e]={exports:{}};return t[e](n,n.exports,r),n.exports}r.m=t,e=[],r.O=(t,i,s,n)=>{if(!i){var a=1/0;for(u=0;u=n)&&Object.keys(r.O).every(e=>r.O[e](i[l]))?i.splice(l--,1):(o=!1,n0&&e[u-1][2]>n;u--)e[u]=e[u-1];e[u]=[i,s,n]},r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},r.d=(e,t)=>{for(var i in t)r.o(t,i)&&!r.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={85:0,721:0};r.O.j=t=>0===e[t];var t=(t,i)=>{var s,n,[a,o,l]=i,c=0;if(a.some(t=>0!==e[t])){for(s in o)r.o(o,s)&&(r.m[s]=o[s]);if(l)var u=l(r)}for(t&&t(i);cr(1868));s=r.O(s)})();
\ No newline at end of file
diff --git a/src/stats/edit.js b/src/stats/edit.js
index bc1dece5b3..364730767b 100644
--- a/src/stats/edit.js
+++ b/src/stats/edit.js
@@ -52,6 +52,7 @@ export default function Edit( { attributes, setAttributes } ) {
style: {
border: 'none',
borderRadius: undefined,
+ boxShadow: undefined,
padding: undefined,
margin: undefined,
background: undefined,
From 033d910c1f2f73f426bf9068902a1891e102ecc5 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Thu, 2 Apr 2026 11:52:21 +0200
Subject: [PATCH 23/32] Address review feedback from jeherve
- Prepend @ to webfinger display in stats block.
- Change branding text to "Powered by the ActivityPub plugin".
- Remove leftover duplicate doc comment opener.
- Include compiled_at timestamp in image cache hash so recompiled
stats invalidate the cached image.
- Skip image URL fetch when selectedUser is not yet set to prevent
double request on block load.
---
includes/cache/class-stats-image.php | 27 +++++++++++++++++++--------
src/stats/edit.js | 5 ++++-
src/stats/render.php | 4 ++--
3 files changed, 25 insertions(+), 11 deletions(-)
diff --git a/includes/cache/class-stats-image.php b/includes/cache/class-stats-image.php
index 32b6f4ee56..51e14d50c5 100644
--- a/includes/cache/class-stats-image.php
+++ b/includes/cache/class-stats-image.php
@@ -114,7 +114,7 @@ public static function get_url( $user_id, $year ) {
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
}
- $hash = self::get_hash();
+ $hash = self::get_hash( $user_id, $year );
$paths = static::get_storage_paths( $user_id );
// Check for cached file using the base class glob pattern.
@@ -156,7 +156,7 @@ public static function serve( $user_id, $year ) {
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
}
- $hash = self::get_hash();
+ $hash = self::get_hash( $user_id, $year );
$paths = static::get_storage_paths( $user_id );
// Check for cached file.
@@ -238,7 +238,7 @@ public static function generate( $user_id, $year ) {
}
// Move to cache dir with a descriptive name, then optimize (WebP conversion).
- $hash = self::get_hash();
+ $hash = self::get_hash( $user_id, $year );
$dest_name = \sprintf( 'stats-%d-%s.png', $year, $hash );
$dest_path = $paths['basedir'] . '/' . $dest_name;
@@ -251,20 +251,31 @@ public static function generate( $user_id, $year ) {
}
/**
- /**
- * Generate a hash based on the active theme.
+ * 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.
*
- * Includes the theme stylesheet and version so cached images are
- * regenerated when the theme (and its colors/fonts) changes.
+ * @param int $user_id The user ID.
+ * @param int $year The year.
*
* @return string The hash string.
*/
- private static function get_hash() {
+ 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 ) );
}
diff --git a/src/stats/edit.js b/src/stats/edit.js
index 364730767b..c381fa7b06 100644
--- a/src/stats/edit.js
+++ b/src/stats/edit.js
@@ -76,7 +76,10 @@ export default function Edit( { attributes, setAttributes } ) {
// Fetch the resolved image URL (cached file or REST endpoint).
const fetchImageUrl = useCallback( () => {
- const endpoint = getImageUrlEndpoint( selectedUser || 'blog', displayYear );
+ if ( ! selectedUser ) {
+ return;
+ }
+ const endpoint = getImageUrlEndpoint( selectedUser, displayYear );
if ( ! endpoint ) {
return;
}
diff --git a/src/stats/render.php b/src/stats/render.php
index 51a144b7b5..05a2edde54 100644
--- a/src/stats/render.php
+++ b/src/stats/render.php
@@ -138,7 +138,7 @@
@@ -234,6 +234,6 @@
From 1fd63e4814ef090c11c61a4e457d803c664ab626 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle