Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2610-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add local caching for remote actor avatars.
87 changes: 86 additions & 1 deletion includes/class-attachments.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Activitypub;

use Activitypub\Collection\Posts;
use Activitypub\Collection\Remote_Actors;

/**
* Attachments processor class.
Expand All @@ -27,18 +28,33 @@ class Attachments {
*/
public static $comments_dir = '/activitypub/comments/';

/**
* Directory for storing actor avatar files.
*
* @var string
*/
public static $actors_dir = '/activitypub/actors/';

/**
* Maximum width for imported images.
*
* @var int
*/
const MAX_IMAGE_DIMENSION = 1200;

/**
* Maximum width for actor avatars.
*
* @var int
*/
const MAX_AVATAR_DIMENSION = 512;

/**
* Initialize the class and set up filters.
*/
public static function init() {
\add_action( 'before_delete_post', array( self::class, 'delete_ap_posts_directory' ) );
\add_action( 'before_delete_post', array( self::class, 'delete_actors_directory' ) );
}

/**
Expand Down Expand Up @@ -207,7 +223,18 @@ private static function import_files_for_object( $attachments, $object_id, $obje
*/
private static function get_storage_paths( $object_id, $object_type ) {
$upload_dir = \wp_upload_dir();
$sub_dir = 'comment' === $object_type ? self::$comments_dir : self::$ap_posts_dir;

switch ( $object_type ) {
case 'comment':
$sub_dir = self::$comments_dir;
break;
case 'actor':
$sub_dir = self::$actors_dir;
break;
default:
$sub_dir = self::$ap_posts_dir;
break;
}

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

return $gallery;
}

/**
* Save a remote actor's avatar locally.
*
* Downloads the avatar image, optimizes it, and stores it in the actors directory.
* Returns the local URL for the saved avatar.
*
* @param int $actor_id The local actor post ID.
* @param string $avatar_url The remote avatar URL.
*
* @return string|false The local avatar URL on success, false on failure.
*/
public static function save_actor_avatar( $actor_id, $avatar_url ) {
// Validate actor_id is a positive integer to prevent path traversal.
$actor_id = (int) $actor_id;
if ( $actor_id <= 0 ) {
return false;
}

if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
return false;
}

// Delete existing avatar files before saving new one.
// This prevents accumulating old avatar files since save_file creates unique filenames.
self::delete_actors_directory( $actor_id );

$attachment_data = array( 'url' => $avatar_url );
$result = self::save_file( $attachment_data, $actor_id, 'actor', self::MAX_AVATAR_DIMENSION );

if ( \is_wp_error( $result ) || ! isset( $result['url'] ) ) {
return false;
}

return $result['url'];
}

/**
* Delete the activitypub files directory for an actor.
*
* @param int $actor_id The actor post ID.
*/
public static function delete_actors_directory( $actor_id ) {
if ( Remote_Actors::POST_TYPE !== \get_post_type( $actor_id ) ) {
return;
}

require_once ABSPATH . 'wp-admin/includes/file.php';

\WP_Filesystem();
global $wp_filesystem;

$activitypub_dir = self::get_storage_paths( $actor_id, 'actor' )['basedir'];

if ( $wp_filesystem->is_dir( $activitypub_dir ) ) {
$wp_filesystem->rmdir( $activitypub_dir, true );
}
}
}
55 changes: 45 additions & 10 deletions includes/collection/class-remote-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Activitypub\Collection;

use Activitypub\Activity\Actor;
use Activitypub\Attachments;
use Activitypub\Http;
use Activitypub\Sanitize;
use Activitypub\Webfinger;
Expand Down Expand Up @@ -111,6 +112,11 @@ public static function create( $actor ) {
\kses_init_filters();
}

// Cache the actor's avatar locally.
if ( ! \is_wp_error( $post_id ) ) {
self::cache_avatar( $post_id, $actor );
}

return $post_id;
}

Expand Down Expand Up @@ -158,6 +164,11 @@ public static function update( $post, $actor ) {
\kses_init_filters();
}

// Re-cache the actor's avatar if it has changed.
if ( ! \is_wp_error( $post_id ) ) {
self::cache_avatar( $post_id, $actor );
}

return $post_id;
}

Expand Down Expand Up @@ -531,12 +542,6 @@ private static function prepare_custom_post_type( $actor ) {
'_activitypub_acct' => $webfinger,
);

// Store avatar URL if available.
$icon = object_to_uri( $actor->get_icon() );
if ( $icon ) {
$meta_input['_activitypub_avatar_url'] = $icon;
}

return array(
'guid' => \esc_url_raw( $actor->get_id() ),
'post_title' => \wp_strip_all_tags( \wp_slash( $actor->get_name() ?: $actor->get_preferred_username() ) ),
Expand Down Expand Up @@ -653,6 +658,9 @@ public static function get_acct( $id ) {
/**
* Get the avatar URL for a remote actor.
*
* Returns the locally cached avatar URL if available, otherwise falls back
* to the default avatar.
*
* @param int $id The ID of the remote actor post.
*
* @return string The avatar URL or empty string if not found.
Expand All @@ -663,7 +671,7 @@ public static function get_avatar_url( $id ) {
return $avatar_url;
}

// If not found in meta, try to extract from post_content JSON.
// If not found in meta, try to extract from post_content JSON and cache it.
$post = \get_post( $id );
if ( ! $post || empty( $post->post_content ) ) {
return '';
Expand All @@ -677,9 +685,36 @@ public static function get_avatar_url( $id ) {
return $default_avatar_url;
}

$avatar_url = object_to_uri( $actor_data['icon'] );
// Cache it in meta for next time.
\update_post_meta( $id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );
return self::cache_avatar( $id, $actor_data );
}

/**
* Cache a remote actor's avatar locally.
*
* Downloads the avatar image, optimizes it (resize/WebP), and stores it locally.
*
* @param int $post_id The actor post ID.
* @param Actor|array|object $actor The actor object or data array.
*
* @return string|null The cached avatar URL, or null if no avatar.
*/
private static function cache_avatar( $post_id, $actor ) {
$data = $actor instanceof Actor ? $actor->to_array() : (array) $actor;
$remote_avatar_url = object_to_uri( $data['icon'] ?? null );

if ( empty( $remote_avatar_url ) ) {
// No avatar to save, clean up any existing avatar.
Attachments::delete_actors_directory( $post_id );
\delete_post_meta( $post_id, '_activitypub_avatar_url' );
return null;
}

// Download and save the avatar locally.
$local_url = Attachments::save_actor_avatar( $post_id, $remote_avatar_url );

// Store the local URL if caching succeeded, otherwise store the remote URL.
$avatar_url = $local_url ?: $remote_avatar_url;
\update_post_meta( $post_id, '_activitypub_avatar_url', \esc_url_raw( $avatar_url ) );

return $avatar_url;
}
Expand Down
Loading