Skip to content
Open
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/fix-3583-avatar-cache-cleanup
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Prevented unused copies of remote profile pictures from accumulating on your server and added automatic cleanup for leftover cached avatars.
173 changes: 173 additions & 0 deletions includes/cache/class-avatar.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

use Activitypub\Collection\Remote_Actors;

use function Activitypub\object_to_uri;

/**
* Avatar cache class.
*
Expand Down Expand Up @@ -125,6 +127,30 @@ public static function maybe_cache( $url, $context, $entity_id = null, $options
return $cached_url ?: $url;
}

/**
* Cache a remote avatar locally, then drop older versions of it.
*
* Overrides the shared write path so that any avatar hash it leaves behind
* for the same actor is removed in the same call. Remote actors change
* their icon URL frequently, and each change would otherwise pile up an
* orphaned copy of the previous image.
*
* @param string $url The remote URL.
* @param string|int $entity_id The entity identifier (actor post ID).
* @param array $options Optional. Additional options.
*
* @return string|false The local URL on success, false on failure.
*/
public static function cache( $url, $entity_id, $options = array() ) {
$cached_url = parent::cache( $url, $entity_id, $options );

if ( $cached_url ) {
self::prune_stale_files( $entity_id, self::generate_hash( $url ) );
}

return $cached_url;
}

/**
* Maybe clean up cached avatar when actor is deleted.
*
Expand Down Expand Up @@ -169,4 +195,151 @@ public static function save( $actor_id, $avatar_url ) {
array( 'max_dimension' => self::MAX_DIMENSION )
);
}

/**
* Get the hash of the avatar currently referenced by an actor.
*
* Reads the actor's icon directly from post content without running the
* remote media filter, so this never triggers a lazy download.
*
* @since unreleased
*
* @param int $post_id The actor post ID.
*
* @return string|false The md5 hash of the current avatar URL, or false if none.
*/
public static function get_actor_avatar_hash( $post_id ) {
$post = \get_post( $post_id );
if ( ! $post || empty( $post->post_content ) ) {
return false;
}

$actor_data = \json_decode( $post->post_content, true );
if ( empty( $actor_data['icon'] ) ) {
return false;
}

$avatar_url = object_to_uri( $actor_data['icon'] );
if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
return false;
}

return self::generate_hash( $avatar_url );
}

/**
* Remove cached avatar files that no longer match the current icon.
*
* Keeps any file whose basename starts with the current hash and deletes
* the rest. Runs after the current avatar is written, so the active file
* is never removed.
*
* @since unreleased
*
* @param int $entity_id The actor post ID.
* @param string $current_hash The hash of the current avatar URL.
*/
public static function prune_stale_files( $entity_id, $current_hash ) {
$paths = static::get_storage_paths( $entity_id );
if ( ! \is_dir( $paths['basedir'] ) ) {
return;
}

$files = \glob( $paths['basedir'] . '/*' );
if ( empty( $files ) ) {
return;
}

$prefix = $current_hash . '.';

foreach ( $files as $file ) {
if ( 0 === \strpos( \basename( $file ), $prefix ) ) {
continue;
}

if ( \is_dir( $file ) ) {
static::delete_directory( $file );
} else {
static::get_filesystem()->delete( $file );
}
}
}

/**
* Clean up stale cached avatars.
*
* Run daily by cron. Deletes orphaned actor directories that no longer
* match an actor post and removes older avatar versions for surviving
* actors. Processed in batches so a large backlog drains over several runs.
*
* @since unreleased
*/
public static function cleanup_actors() {
// Lock the cleanup with an autoload-disabled option so overlapping
// cron workers never run it twice at once.
if ( ! \add_option( 'activitypub_avatar_cache_cleanup_lock', time(), '', false ) ) {
$lock_time = (int) \get_option( 'activitypub_avatar_cache_cleanup_lock' );
if ( $lock_time && ( time() - $lock_time ) < 30 * MINUTE_IN_SECONDS ) {
return;
}
\delete_option( 'activitypub_avatar_cache_cleanup_lock' );
if ( ! \add_option( 'activitypub_avatar_cache_cleanup_lock', time(), '', false ) ) {
return;
}
}

$upload_dir = \wp_upload_dir();
$root = $upload_dir['basedir'] . static::get_base_dir();
$dirs = \glob( $root . '/*', GLOB_ONLYDIR );

if ( empty( $dirs ) ) {
\delete_option( 'activitypub_avatar_cache_cleanup_lock' );
\delete_option( 'activitypub_avatar_cache_cursor' );
return;
}

// Sort so the resume position stays stable between runs.
\sort( $dirs );

/**
* Filters how many actor directories are scanned per cleanup run.
*
* @since unreleased
*
* @param int $limit The maximum number of directories to scan.
*/
$limit = \apply_filters( 'activitypub_cleanup_actor_cache_limit', 100 );

// Resume where the previous run stopped so a large backlog drains
// over several runs instead of always revisiting the first batch.
$total = \count( $dirs );
$start = (int) \get_option( 'activitypub_avatar_cache_cursor', 0 ) % $total;
$batch = \array_slice( $dirs, $start, \max( 1, (int) $limit ) );

foreach ( $batch as $dir ) {
$dirname = \basename( $dir );

// Only touch directories with a numeric name, to stay clear of junk.
if ( ! \preg_match( '/^\d+$/', $dirname ) ) {
continue;
}

$post_id = (int) $dirname;
$post = \get_post( $post_id );

// Remove directories that no longer belong to an actor post.
if ( ! $post || Remote_Actors::POST_TYPE !== $post->post_type ) {
static::delete_directory( $dir );
continue;
}

$hash = self::get_actor_avatar_hash( $post_id );
if ( $hash ) {
self::prune_stale_files( $post_id, $hash );
}
}

\update_option( 'activitypub_avatar_cache_cursor', ( $start + \count( $batch ) ) % $total, false );
\delete_option( 'activitypub_avatar_cache_cleanup_lock' );
}
}
5 changes: 5 additions & 0 deletions includes/class-scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Cache\Avatar;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Inbox;
use Activitypub\Collection\Outbox;
Expand All @@ -35,6 +36,7 @@ class Scheduler {
const SCHEDULES = array(
'activitypub_update_remote_actors' => 'hourly',
'activitypub_cleanup_remote_actors' => 'daily',
'activitypub_cleanup_actor_cache' => 'daily',
'activitypub_reprocess_outbox' => 'hourly',
'activitypub_outbox_purge' => 'daily',
'activitypub_inbox_purge' => 'daily',
Expand Down Expand Up @@ -84,6 +86,9 @@ public static function init() {
\add_action( 'activitypub_update_remote_actors', array( self::class, 'update_remote_actors' ) );
\add_action( 'activitypub_cleanup_remote_actors', array( self::class, 'cleanup_remote_actors' ) );

// Cached avatar cleanup.
\add_action( 'activitypub_cleanup_actor_cache', array( Avatar::class, 'cleanup_actors' ) );

// Event callbacks.
\add_action( 'activitypub_async_batch', array( self::class, 'async_batch' ), 10, 99 );
\add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) );
Expand Down
Loading