Skip to content

Conversation

@pfefferle
Copy link
Member

@pfefferle pfefferle commented Dec 9, 2025

fixes #926

Proposed changes:

  • Cache remote actor avatars locally to reduce external dependencies and improve load times
  • Download and optimize avatars when actors are created or updated
  • Reuse the image optimization from Add image optimization for imported attachments #2609 (resize to 1200px, convert to WebP)
  • Track remote avatar URL separately to detect changes and avoid unnecessary re-downloads
  • Clean up cached avatar files when actor is deleted
  • Gracefully fall back to remote URL if local caching fails

Other information:

  • Have you written new tests for your changes, if applicable?

Uses existing test coverage - the avatar caching gracefully handles test environments where HTTP downloads aren't mocked.

Note: This PR depends on #2609 (image optimization) - set as base branch.

Testing instructions:

  • Set up a local WordPress environment with wp-env
  • Follow a remote ActivityPub actor (e.g., from Mastodon)
  • Check wp-content/uploads/activitypub/actors/{actor_id}/ for the cached avatar
  • The avatar should be resized and converted to WebP format
  • Verify the avatar displays correctly in followers/following lists
  • Update the remote actor's avatar on their server
  • Wait for the scheduled update (or manually trigger activitypub_update_remote_actors)
  • Verify the new avatar is downloaded and cached

Changelog entry

  • Automatically create a changelog entry from the details below.
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Added - for new features
  • Changed - for changes in existing functionality
  • Deprecated - for soon-to-be removed features
  • Removed - for now removed features
  • Fixed - for any bug fixes
  • Security - in case of vulnerabilities

Message

Add local caching for remote actor avatars.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds local caching for remote ActivityPub actor avatars to reduce external dependencies and improve performance. When remote actors are created or updated, their avatars are downloaded, optimized (resized to 1200px max and converted to WebP), and stored locally in /activitypub/actors/{actor_id}/. The implementation gracefully falls back to remote URLs if caching fails and cleans up cached files when actors are deleted.

Key changes:

  • Avatar downloading and local caching with image optimization during actor creation/update
  • Automatic cleanup of cached avatar files when actors are deleted
  • Fallback to remote URL if local caching fails

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 11 comments.

File Description
includes/collection/class-remote-actors.php Added cache_avatar() method to download and cache avatars during actor create/update; updated get_avatar_url() to attempt local caching for legacy data; removed avatar URL storage from prepare_custom_post_type() since it's now handled in cache_avatar()
includes/class-attachments.php Added cache_actor_avatar() to download, optimize, and store avatar files; added delete_actor_avatar_files() to clean up avatar directories; added delete_actor_avatar_on_delete() hook to remove avatars when actors are deleted; added $actors_dir constant for avatar storage path
Comments suppressed due to low confidence (1)

includes/collection/class-remote-actors.php:667

  • The comment says "Returns the locally cached avatar URL if available, otherwise falls back to the default avatar", but the actual implementation attempts to download and cache the remote avatar if not found in meta (line 690). This could cause unexpected delays during page rendering if the avatar hasn't been cached yet.

Consider updating the docblock to accurately reflect the behavior:

/**
 * Get the avatar URL for a remote actor.
 *
 * Returns the locally cached avatar URL if available. If not cached,
 * attempts to download and cache the remote avatar. Falls back to
 * the default avatar if no icon is available.
 *
 * @param int $id The ID of the remote actor post.
 *
 * @return string The avatar URL or empty string if not found.
 */
	/**
	 * 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.
	 */

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (3)

includes/collection/class-remote-actors.php:667

  • The documentation comment says "Returns the locally cached avatar URL if available, otherwise falls back to the default avatar", but this is misleading. The method actually has three cases:
  1. Returns cached URL from meta (line 670)
  2. Returns default avatar if no icon exists (line 682)
  3. Attempts to cache and returns either local URL or remote URL (line 692)

The description should be updated to accurately reflect the fallback behavior:

* Returns the locally cached avatar URL if available. If not cached, attempts to
* download and cache the avatar. Falls back to the remote URL if caching fails,
* or to the default avatar if no icon exists.
	/**
	 * 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.
	 */

includes/class-attachments.php:607

  • This save_file implementation uses sanitize_file_name( basename( $url_path ) ) and then writes the downloaded content directly into wp-content/uploads/activitypub/... without enforcing that the file is actually an image or that its extension is safe. Because save_actor_avatar and other callers pass attacker-controlled remote URLs here, a malicious ActivityPub actor can point their avatar at a .php payload and have it written into the uploads tree (e.g., wp-content/uploads/activitypub/actors/{id}/shell.php), which on many WordPress setups will be executed as PHP when requested, yielding remote code execution. To mitigate, strictly validate MIME type and extension before moving the file (only allow image types like jpg/png/webp, strip or reject disallowed extensions) and configure the activitypub uploads subdirectories so that PHP execution is disabled at the web server level.
		$url_path  = \wp_parse_url( $attachment_data['url'], PHP_URL_PATH );
		$file_name = \sanitize_file_name( \basename( $url_path ) );
		$file_path = $paths['basedir'] . '/' . $file_name;

		// Initialize filesystem if needed.
		\WP_Filesystem();
		global $wp_filesystem;

		// Make sure file name is unique.
		$counter = 1;
		while ( $wp_filesystem->exists( $file_path ) ) {
			$path_info = pathinfo( $file_name );
			$file_name = $path_info['filename'] . '-' . $counter;
			if ( ! empty( $path_info['extension'] ) ) {
				$file_name .= '.' . $path_info['extension'];
			}
			$file_path = $paths['basedir'] . '/' . $file_name;
			++$counter;
		}

		// Move file to destination.
		if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) {
			\wp_delete_file( $tmp_file );
			return new \WP_Error( 'file_move_failed', \__( 'Failed to move file to destination.', 'activitypub' ) );
		}

		// Optimize images (resize and convert to WebP).
		$optimized = self::optimize_image( $file_path, $max_dimension );
		if ( $optimized['changed'] ) {
			$file_path = $optimized['path'];
			$file_name = \basename( $file_path );
		}

		// Get mime type and validate file.
		$file_info = \wp_check_filetype_and_ext( $file_path, $file_name );
		$mime_type = $file_info['type'] ?? $attachment_data['mediaType'] ?? '';

		return array(
			'url'       => $paths['baseurl'] . '/' . $file_name,
			'mime_type' => $mime_type,

includes/class-attachments.php:556

  • save_file downloads whatever URL is provided in attachment_data['url'] via download_url without any host or address-range restrictions, and save_actor_avatar passes remote actor avatar URLs directly into this path. A malicious ActivityPub actor can set their icon URL to an internal or sensitive endpoint (e.g. an internal HTTP service or metadata URL), causing the server to perform SSRF requests and persist the response under wp-content/uploads/activitypub/actors/, where it can be fetched back over the internet. To fix this, validate avatar/attachment URLs before calling download_url (e.g., enforce http/https, block private/loopback/metadata IP ranges, and optionally maintain an allowlist/denylist of hosts) and fail closed when validation fails.
	private static function save_file( $attachment_data, $object_id, $object_type, $max_dimension = self::MAX_IMAGE_DIMENSION ) {
		if ( ! \function_exists( 'download_url' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		// Download remote URL.
		$tmp_file = \download_url( $attachment_data['url'] );


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pfefferle pfefferle force-pushed the add/image-optimization branch from ac8f90f to 65fbd48 Compare December 9, 2025 15:35
@pfefferle pfefferle requested a review from obenland December 9, 2025 15:52
Base automatically changed from add/image-optimization to trunk December 9, 2025 17:20
obenland
obenland previously approved these changes Dec 9, 2025
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

pfefferle and others added 9 commits December 9, 2025 18:32
Optimize imported ActivityPub images by resizing large images and
converting to WebP format for reduced file sizes:

- Add MAX_IMAGE_DIMENSION constant (1200px)
- Add optimize_image() method using WordPress image editor
- Convert images to WebP when supported, fallback to JPEG
- Skip GIFs (may be animated) and SVGs (vector graphics)
- Update save_file() and save_attachment() to optimize images
- Update tests to handle WebP conversion
Address Copilot feedback:
- Early return when no resize needed for PNG/WebP (avoids returning true)
- Simplify result handling - editor->save() always returns array
- Set changed=true when any save operation occurs
Allow callers to specify the maximum dimension for image resizing.
Allow callers to specify custom image dimensions when saving files,
enabling reuse for different contexts like avatars.
- Return only file path from optimize_image instead of array with changed flag
- Let WordPress auto-detect mime type instead of manually setting it
- Add temp file extension handling for proper mime type detection
- Add get_unique_path helper to prevent file collisions during format conversion
- Move WP_Filesystem initialization to function start
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
- Resize avatars to 100px max (MAX_AVATAR_DIMENSION constant)
- Store local URL in _activitypub_avatar_url meta
- Clean up avatar files when actor is deleted
- Fall back to remote URL if local caching fails
- Use get_storage_paths() for actor avatar storage paths.
- Rename cache_actor_avatar to save_actor_avatar.
- Rename delete_actor_avatar_files to delete_actors_directory.
- Remove separate on_delete hook wrapper function.
- Add 'actor' type support to get_storage_paths().
- Test successful avatar save with valid URL.
- Test empty and invalid URL handling.
- Test download error handling.
- Test avatar replacement.
- Test directory deletion.
- Test non-actor post type protection.
pfefferle and others added 4 commits December 9, 2025 18:34
- Test successful avatar save with valid URL.
- Test empty and invalid URL handling.
- Test download error handling.
- Test avatar replacement.
- Test directory deletion.
- Test non-actor post type protection.
- Swap save_actor_avatar parameter order (actor_id first)
- Increase MAX_AVATAR_DIMENSION to 512px for better quality
- Add actor_id validation to prevent path traversal
- Add is_array check to cache_avatar for flexibility
- Return avatar_url from cache_avatar
- Use short ternary for avatar_url assignment
- Update tests to match new parameter order
@pfefferle pfefferle requested a review from obenland December 9, 2025 17:46
@pfefferle pfefferle merged commit 3f74778 into trunk Dec 9, 2025
10 checks passed
@pfefferle pfefferle deleted the add/avatar-caching branch December 9, 2025 17:54
@nickbohle
Copy link

Hi. Have you tested this with Automattic's Gravatar Enhanced? The plug-in caches not only Gravatars in comments, but also the Fediverse avatars.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Local Cache for Profile-Pictures

5 participants