Skip to content

Commit 2c05ec1

Browse files
committed
Add local caching for remote actor avatars
Cache avatars from remote actors locally to reduce external dependencies and improve load times: - Add actors directory constant and cache_actor_avatar method - Download and optimize avatars on actor create/update - Store local URL in _activitypub_avatar_url meta - Track remote URL in _activitypub_remote_avatar_url for change detection - Only re-download when remote URL changes - Clean up avatar files when actor is deleted - Fall back to remote URL if local caching fails
1 parent b2e6fa5 commit 2c05ec1

File tree

2 files changed

+177
-9
lines changed

2 files changed

+177
-9
lines changed

includes/class-attachments.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace Activitypub;
99

1010
use Activitypub\Collection\Posts;
11+
use Activitypub\Collection\Remote_Actors;
1112

1213
/**
1314
* Attachments processor class.
@@ -27,6 +28,13 @@ class Attachments {
2728
*/
2829
public static $comments_dir = '/activitypub/comments/';
2930

31+
/**
32+
* Directory for storing actor avatar files.
33+
*
34+
* @var string
35+
*/
36+
public static $actors_dir = '/activitypub/actors/';
37+
3038
/**
3139
* Maximum width for imported images.
3240
*
@@ -39,6 +47,20 @@ class Attachments {
3947
*/
4048
public static function init() {
4149
\add_action( 'before_delete_post', array( self::class, 'delete_ap_posts_directory' ) );
50+
\add_action( 'before_delete_post', array( self::class, 'delete_actor_avatar_on_delete' ) );
51+
}
52+
53+
/**
54+
* Delete the actor avatar directory when an actor is deleted.
55+
*
56+
* @param int $post_id The post ID.
57+
*/
58+
public static function delete_actor_avatar_on_delete( $post_id ) {
59+
if ( Remote_Actors::POST_TYPE !== \get_post_type( $post_id ) ) {
60+
return;
61+
}
62+
63+
self::delete_actor_avatar_files( $post_id );
4264
}
4365

4466
/**
@@ -933,4 +955,101 @@ private static function get_files_gallery_block( $files ) {
933955

934956
return $gallery;
935957
}
958+
959+
/**
960+
* Cache a remote actor's avatar locally.
961+
*
962+
* Downloads the avatar image, optimizes it, and stores it in the actors directory.
963+
* Returns the local URL for the cached avatar.
964+
*
965+
* @param string $avatar_url The remote avatar URL.
966+
* @param int $actor_id The local actor post ID.
967+
*
968+
* @return string|false The local avatar URL on success, false on failure.
969+
*/
970+
public static function cache_actor_avatar( $avatar_url, $actor_id ) {
971+
if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
972+
return false;
973+
}
974+
975+
if ( ! \function_exists( 'download_url' ) ) {
976+
require_once ABSPATH . 'wp-admin/includes/file.php';
977+
}
978+
979+
// Download remote avatar.
980+
$tmp_file = \download_url( $avatar_url );
981+
982+
if ( \is_wp_error( $tmp_file ) ) {
983+
return false;
984+
}
985+
986+
// Verify the temp file exists and is readable.
987+
if ( ! \file_exists( $tmp_file ) || ! \is_readable( $tmp_file ) || 0 === \filesize( $tmp_file ) ) {
988+
\wp_delete_file( $tmp_file );
989+
return false;
990+
}
991+
992+
// Get storage paths for actor avatars.
993+
$upload_dir = \wp_upload_dir();
994+
$actor_dir = $upload_dir['basedir'] . self::$actors_dir . $actor_id;
995+
$actor_url = $upload_dir['baseurl'] . self::$actors_dir . $actor_id;
996+
997+
// Create directory if it doesn't exist.
998+
\wp_mkdir_p( $actor_dir );
999+
1000+
// Delete existing avatar files for this actor.
1001+
self::delete_actor_avatar_files( $actor_id );
1002+
1003+
// Generate file name from URL.
1004+
$url_path = \wp_parse_url( $avatar_url, PHP_URL_PATH );
1005+
$file_name = \sanitize_file_name( \basename( $url_path ) );
1006+
1007+
// Ensure file has an extension.
1008+
if ( ! \pathinfo( $file_name, PATHINFO_EXTENSION ) ) {
1009+
$file_name .= '.jpg';
1010+
}
1011+
1012+
$file_path = $actor_dir . '/' . $file_name;
1013+
1014+
// Initialize filesystem.
1015+
\WP_Filesystem();
1016+
global $wp_filesystem;
1017+
1018+
// Move file to destination.
1019+
if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) {
1020+
\wp_delete_file( $tmp_file );
1021+
return false;
1022+
}
1023+
1024+
// Optimize the avatar image.
1025+
$optimized = self::optimize_image( $file_path );
1026+
if ( $optimized['changed'] ) {
1027+
$file_path = $optimized['path'];
1028+
$file_name = \basename( $file_path );
1029+
}
1030+
1031+
return $actor_url . '/' . $file_name;
1032+
}
1033+
1034+
/**
1035+
* Delete all avatar files for an actor.
1036+
*
1037+
* @param int $actor_id The actor post ID.
1038+
*/
1039+
public static function delete_actor_avatar_files( $actor_id ) {
1040+
$upload_dir = \wp_upload_dir();
1041+
$actor_dir = $upload_dir['basedir'] . self::$actors_dir . $actor_id;
1042+
1043+
if ( ! \is_dir( $actor_dir ) ) {
1044+
return;
1045+
}
1046+
1047+
require_once ABSPATH . 'wp-admin/includes/file.php';
1048+
1049+
\WP_Filesystem();
1050+
global $wp_filesystem;
1051+
1052+
// Delete the entire actor directory.
1053+
$wp_filesystem->delete( $actor_dir, true );
1054+
}
9361055
}

includes/collection/class-remote-actors.php

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace Activitypub\Collection;
99

1010
use Activitypub\Activity\Actor;
11+
use Activitypub\Attachments;
1112
use Activitypub\Http;
1213
use Activitypub\Sanitize;
1314
use Activitypub\Webfinger;
@@ -111,6 +112,11 @@ public static function create( $actor ) {
111112
\kses_init_filters();
112113
}
113114

115+
// Cache the actor's avatar locally.
116+
if ( ! \is_wp_error( $post_id ) ) {
117+
self::cache_avatar( $post_id, $actor );
118+
}
119+
114120
return $post_id;
115121
}
116122

@@ -158,6 +164,11 @@ public static function update( $post, $actor ) {
158164
\kses_init_filters();
159165
}
160166

167+
// Re-cache the actor's avatar if it has changed.
168+
if ( ! \is_wp_error( $post_id ) ) {
169+
self::cache_avatar( $post_id, $actor );
170+
}
171+
161172
return $post_id;
162173
}
163174

@@ -531,12 +542,6 @@ private static function prepare_custom_post_type( $actor ) {
531542
'_activitypub_acct' => $webfinger,
532543
);
533544

534-
// Store avatar URL if available.
535-
$icon = object_to_uri( $actor->get_icon() );
536-
if ( $icon ) {
537-
$meta_input['_activitypub_avatar_url'] = $icon;
538-
}
539-
540545
return array(
541546
'guid' => \esc_url_raw( $actor->get_id() ),
542547
'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?: $actor->get_preferred_username() ) ),
@@ -653,6 +658,9 @@ public static function get_acct( $id ) {
653658
/**
654659
* Get the avatar URL for a remote actor.
655660
*
661+
* Returns the locally cached avatar URL if available, otherwise falls back
662+
* to the default avatar.
663+
*
656664
* @param int $id The ID of the remote actor post.
657665
*
658666
* @return string The avatar URL or empty string if not found.
@@ -663,7 +671,7 @@ public static function get_avatar_url( $id ) {
663671
return $avatar_url;
664672
}
665673

666-
// If not found in meta, try to extract from post_content JSON.
674+
// If not found in meta, try to extract from post_content JSON and cache it.
667675
$post = \get_post( $id );
668676
if ( ! $post || empty( $post->post_content ) ) {
669677
return '';
@@ -677,10 +685,51 @@ public static function get_avatar_url( $id ) {
677685
return $default_avatar_url;
678686
}
679687

680-
$avatar_url = object_to_uri( $actor_data['icon'] );
681-
// Cache it in meta for next time.
688+
// Try to cache the avatar locally.
689+
$remote_avatar_url = object_to_uri( $actor_data['icon'] );
690+
$local_url = Attachments::cache_actor_avatar( $remote_avatar_url, $id );
691+
692+
$avatar_url = $local_url ? $local_url : $remote_avatar_url;
682693
\update_post_meta( $id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );
683694

684695
return $avatar_url;
685696
}
697+
698+
/**
699+
* Cache a remote actor's avatar locally.
700+
*
701+
* Downloads the avatar image, optimizes it (resize/WebP), and stores it locally.
702+
* Only re-downloads if the remote avatar URL has changed.
703+
*
704+
* @param int $post_id The actor post ID.
705+
* @param Actor $actor The actor object.
706+
*/
707+
private static function cache_avatar( $post_id, $actor ) {
708+
$remote_avatar_url = object_to_uri( $actor->get_icon() );
709+
710+
if ( empty( $remote_avatar_url ) ) {
711+
// No avatar to cache, clean up any existing cached avatar.
712+
Attachments::delete_actor_avatar_files( $post_id );
713+
\delete_post_meta( $post_id, '_activitypub_avatar_url' );
714+
\delete_post_meta( $post_id, '_activitypub_remote_avatar_url' );
715+
return;
716+
}
717+
718+
// Check if avatar URL has changed since last cache.
719+
$stored_remote_url = \get_post_meta( $post_id, '_activitypub_remote_avatar_url', true );
720+
if ( $stored_remote_url === $remote_avatar_url ) {
721+
// Remote URL hasn't changed, skip re-downloading.
722+
return;
723+
}
724+
725+
// Store the remote URL for future change detection.
726+
\update_post_meta( $post_id, '_activitypub_remote_avatar_url', \esc_url_raw( $remote_avatar_url ) );
727+
728+
// Download and cache the avatar locally.
729+
$local_url = Attachments::cache_actor_avatar( $remote_avatar_url, $post_id );
730+
731+
// Store the local URL if caching succeeded, otherwise store the remote URL.
732+
$avatar_url = $local_url ? $local_url : $remote_avatar_url;
733+
\update_post_meta( $post_id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );
734+
}
686735
}

0 commit comments

Comments
 (0)