Skip to content

Commit 3f74778

Browse files
authored
Add local caching for remote actor avatars (#2610)
1 parent 9c54846 commit 3f74778

File tree

4 files changed

+297
-11
lines changed

4 files changed

+297
-11
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Add local caching for remote actor avatars.

includes/class-attachments.php

Lines changed: 86 additions & 1 deletion
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,18 +28,33 @@ 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
*
3341
* @var int
3442
*/
3543
const MAX_IMAGE_DIMENSION = 1200;
3644

45+
/**
46+
* Maximum width for actor avatars.
47+
*
48+
* @var int
49+
*/
50+
const MAX_AVATAR_DIMENSION = 512;
51+
3752
/**
3853
* Initialize the class and set up filters.
3954
*/
4055
public static function init() {
4156
\add_action( 'before_delete_post', array( self::class, 'delete_ap_posts_directory' ) );
57+
\add_action( 'before_delete_post', array( self::class, 'delete_actors_directory' ) );
4258
}
4359

4460
/**
@@ -207,7 +223,18 @@ private static function import_files_for_object( $attachments, $object_id, $obje
207223
*/
208224
private static function get_storage_paths( $object_id, $object_type ) {
209225
$upload_dir = \wp_upload_dir();
210-
$sub_dir = 'comment' === $object_type ? self::$comments_dir : self::$ap_posts_dir;
226+
227+
switch ( $object_type ) {
228+
case 'comment':
229+
$sub_dir = self::$comments_dir;
230+
break;
231+
case 'actor':
232+
$sub_dir = self::$actors_dir;
233+
break;
234+
default:
235+
$sub_dir = self::$ap_posts_dir;
236+
break;
237+
}
211238

212239
return array(
213240
'basedir' => $upload_dir['basedir'] . $sub_dir . $object_id,
@@ -958,4 +985,62 @@ private static function get_files_gallery_block( $files ) {
958985

959986
return $gallery;
960987
}
988+
989+
/**
990+
* Save a remote actor's avatar locally.
991+
*
992+
* Downloads the avatar image, optimizes it, and stores it in the actors directory.
993+
* Returns the local URL for the saved avatar.
994+
*
995+
* @param int $actor_id The local actor post ID.
996+
* @param string $avatar_url The remote avatar URL.
997+
*
998+
* @return string|false The local avatar URL on success, false on failure.
999+
*/
1000+
public static function save_actor_avatar( $actor_id, $avatar_url ) {
1001+
// Validate actor_id is a positive integer to prevent path traversal.
1002+
$actor_id = (int) $actor_id;
1003+
if ( $actor_id <= 0 ) {
1004+
return false;
1005+
}
1006+
1007+
if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
1008+
return false;
1009+
}
1010+
1011+
// Delete existing avatar files before saving new one.
1012+
// This prevents accumulating old avatar files since save_file creates unique filenames.
1013+
self::delete_actors_directory( $actor_id );
1014+
1015+
$attachment_data = array( 'url' => $avatar_url );
1016+
$result = self::save_file( $attachment_data, $actor_id, 'actor', self::MAX_AVATAR_DIMENSION );
1017+
1018+
if ( \is_wp_error( $result ) || ! isset( $result['url'] ) ) {
1019+
return false;
1020+
}
1021+
1022+
return $result['url'];
1023+
}
1024+
1025+
/**
1026+
* Delete the activitypub files directory for an actor.
1027+
*
1028+
* @param int $actor_id The actor post ID.
1029+
*/
1030+
public static function delete_actors_directory( $actor_id ) {
1031+
if ( Remote_Actors::POST_TYPE !== \get_post_type( $actor_id ) ) {
1032+
return;
1033+
}
1034+
1035+
require_once ABSPATH . 'wp-admin/includes/file.php';
1036+
1037+
\WP_Filesystem();
1038+
global $wp_filesystem;
1039+
1040+
$activitypub_dir = self::get_storage_paths( $actor_id, 'actor' )['basedir'];
1041+
1042+
if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
1043+
$wp_filesystem->rmdir( $activitypub_dir, true );
1044+
}
1045+
}
9611046
}

includes/collection/class-remote-actors.php

Lines changed: 45 additions & 10 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,9 +685,36 @@ 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.
682-
\update_post_meta( $id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );
688+
return self::cache_avatar( $id, $actor_data );
689+
}
690+
691+
/**
692+
* Cache a remote actor's avatar locally.
693+
*
694+
* Downloads the avatar image, optimizes it (resize/WebP), and stores it locally.
695+
*
696+
* @param int $post_id The actor post ID.
697+
* @param Actor|array|object $actor The actor object or data array.
698+
*
699+
* @return string|null The cached avatar URL, or null if no avatar.
700+
*/
701+
private static function cache_avatar( $post_id, $actor ) {
702+
$data = $actor instanceof Actor ? $actor->to_array() : (array) $actor;
703+
$remote_avatar_url = object_to_uri( $data['icon'] ?? null );
704+
705+
if ( empty( $remote_avatar_url ) ) {
706+
// No avatar to save, clean up any existing avatar.
707+
Attachments::delete_actors_directory( $post_id );
708+
\delete_post_meta( $post_id, '_activitypub_avatar_url' );
709+
return null;
710+
}
711+
712+
// Download and save the avatar locally.
713+
$local_url = Attachments::save_actor_avatar( $post_id, $remote_avatar_url );
714+
715+
// Store the local URL if caching succeeded, otherwise store the remote URL.
716+
$avatar_url = $local_url ?: $remote_avatar_url;
717+
\update_post_meta( $post_id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );
683718

684719
return $avatar_url;
685720
}

0 commit comments

Comments
 (0)