Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a89f6dc
Add images to federated comments using the existing media cache
pfefferle Oct 1, 2026
6dd12c2
Harden imported content and fix comment image handling
pfefferle Oct 1, 2026
a572a43
Scope comment image rendering and preserve linked attachments
pfefferle Oct 2, 2026
1f94ea4
Qualify WordPress post deletion in media cache test
pfefferle Oct 2, 2026
9ee3786
Cover single attachment normalization and preserved media fields
pfefferle Oct 2, 2026
d08f946
Accept image-only inbox replies and preserve image descriptions
pfefferle Oct 2, 2026
4bd6520
Reject malformed and relative remote attachment URLs
pfefferle Oct 2, 2026
25a784b
Limit image attachments in Fediverse comments
pfefferle Oct 3, 2026
b2aa22f
Validate remote attachments before generating image blocks
pfefferle Oct 6, 2026
4b240d6
Bound comment attachment validation and accept singular URLs
pfefferle Oct 8, 2026
0588a9b
Merge branch 'trunk' into fix/comment-images
pfefferle Oct 9, 2026
0ee5c50
Merge remote-tracking branch 'origin/trunk' into fix/comment-images
pfefferle Oct 19, 2026
a28dd61
keep the host check for comment images
pfefferle Oct 19, 2026
73ebc0b
document the attachment shape
pfefferle Oct 19, 2026
5277395
Merge branch 'trunk' into fix/comment-images
pfefferle Oct 20, 2026
ad9a796
make comment images opt-in
pfefferle Oct 20, 2026
c0fb23e
accept image-only objects only as replies
pfefferle Oct 21, 2026
2971a2a
hide stored images when comment images are off
pfefferle Oct 21, 2026
db3b76d
reuse the image check in validation
pfefferle Oct 21, 2026
8c5737a
drop hidden images and skip spam
pfefferle Oct 21, 2026
9c2fdee
lazy load comment images
pfefferle Oct 21, 2026
c48ef01
Merge branch 'trunk' into fix/comment-images
pfefferle Oct 22, 2026
e376be3
Merge remote-tracking branch 'origin/trunk' into fix/comment-images
pfefferle Oct 23, 2026
72afa7d
Merge branch 'trunk' into fix/comment-images
pfefferle Oct 23, 2026
a3304cd
cache comment images per comment and remove them with the comment
pfefferle Oct 23, 2026
1fd729d
only show images of approved comments and skip them for reactions
pfefferle Oct 23, 2026
6fa369a
some cleanups
pfefferle Oct 23, 2026
05925f9
remove comment images also when caching is off
pfefferle Oct 23, 2026
a675d69
merge the cache tests
pfefferle Oct 23, 2026
c813268
fix phpstan
pfefferle Oct 23, 2026
be5d54c
move all comment media parts to Comment_Media
pfefferle Oct 23, 2026
798c684
no host checks before the signature check and show hidden images as text
pfefferle Oct 23, 2026
1606a9c
show only cached comment images
pfefferle Oct 23, 2026
8208573
some more fixes
pfefferle Oct 23, 2026
34d05cb
some cleanups
pfefferle Oct 23, 2026
9a0ad4e
keep only images the comment still shows
pfefferle Oct 23, 2026
ee524d4
remove comment images when they are turned off
pfefferle Oct 23, 2026
827c0a9
remove images downloaded after comment images are turned off
pfefferle Oct 24, 2026
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-comment-images
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add an option to show images attached to Fediverse replies in comments, with local caching and image descriptions.
195 changes: 195 additions & 0 deletions includes/cache/class-comment-media.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php
/**
* Comment media cache class.
*
* @package Activitypub
*/

namespace Activitypub\Cache;

use Activitypub\Blocks;

/**
* Comment media cache class.
*
* Handles lazy caching of images attached to remote comments.
* Images are stored in /wp-content/uploads/activitypub/comments/{comment_id}/
* and removed as soon as the comment is deleted, edited, or no longer approved.
*
* @since unreleased
*/
class Comment_Media extends Media {
/**
* Context identifier for the filter.
*
* @var string
*/
const CONTEXT = 'comment_media';

/**
* Base directory for comment images.
*
* @var string
*/
const BASE_DIR = '/activitypub/comments/';

/**
* Get the cache type identifier.
*
* @return string Cache type.
*/
public static function get_type() {
return 'comment_media';
}

/**
* Get the base directory path relative to uploads.
*
* @return string Base directory path.
*/
public static function get_base_dir() {
return self::BASE_DIR;
}

/**
* Get the context identifier for the filter.
*
* @return string Context identifier.
*/
public static function get_context() {
return self::CONTEXT;
}

/**
* Initialize the cache handler.
*
* The cleanup is registered separately by register_cleanup().
*
* @return void
*/
public static function init() {
if ( ! self::is_enabled() ) {
return;
}

\add_filter( 'activitypub_remote_media_url', array( self::class, 'maybe_cache' ), 10, 4 );
}

/**
* Register the hooks that remove cached images with their comment.
*
* Removing a comment has to remove its images from the server, also when caching was
* disabled after they were cached, so this runs whether caching is enabled or not.
* Files are cached lazily, so a restored comment downloads them again.
*
* @return void
*/
public static function register_cleanup() {
\add_action( 'edit_comment', array( self::class, 'clear_cached_images' ) );
// Deleting a comment also transitions it, to 'delete'.
\add_action( 'transition_comment_status', array( self::class, 'maybe_cleanup_comment' ), 10, 3 );
\add_action( 'update_option_activitypub_allow_comment_images', array( self::class, 'maybe_clear_all_images' ), 10, 2 );
}

/**
* Remove all cached comment images when comment images are turned off.
*
* Files are cached lazily, so turning the option on again downloads them again.
*
* @param mixed $old_value The old option value.
* @param mixed $value The new option value.
*
* @return void
*/
public static function maybe_clear_all_images( $old_value, $value ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for action signature.
if ( ! $value ) {
self::delete_directory( \wp_upload_dir()['basedir'] . self::BASE_DIR );
}
}

/**
* Cache a remote comment image locally.
*
* @param string $url The remote URL.
* @param string|int $entity_id The entity identifier (comment 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() ) {
$local_url = parent::cache( $url, $entity_id, $options );

/*
* A comment removed or edited while its image downloads loses its directory, and
* File::cache() re-creates it to store the file. Remove it again unless comment images
* are still on and the comment is still approved and has the image, nothing would clean
* it up later.
*/
if ( $local_url && ! self::shows_image( $entity_id, $url ) ) {
self::clear_cached_images( (int) $entity_id );

return false;
}

return $local_url;
}

/**
* Whether comment images are on and an approved comment still has an image.
*
* @param string|int $comment_id The comment ID.
* @param string $url The remote image URL.
*
* @return bool True if the comment shows the image.
*/
private static function shows_image( $comment_id, $url ) {
// The option may have been turned off while the image downloaded.
if ( ! \get_option( 'activitypub_allow_comment_images' ) ) {
return false;
}

// The comment may have changed in another request while the image downloaded.
\wp_cache_delete( (int) $comment_id, 'comment' );
$comment = \get_comment( $comment_id );

if ( ! $comment || 'approved' !== \wp_get_comment_status( $comment ) ) {
return false;
}

foreach ( \parse_blocks( $comment->comment_content ) as $block ) {
if ( 'activitypub/image' === $block['blockName'] && Blocks::get_image_url( $block['attrs'] ) === $url ) {
return true;
}
}

return false;
}

/**
* Maybe clean up cached images when a comment is no longer approved.
*
* @param string $new_status The new comment status.
* @param string $old_status The old comment status.
* @param \WP_Comment $comment The comment.
*
* @return void
*/
public static function maybe_cleanup_comment( $new_status, $old_status, $comment ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for action signature.
if ( 'approved' !== $new_status ) {
self::clear_cached_images( (int) $comment->comment_ID );
}
}

/**
* Remove the cached images of a comment.
*
* Files are cached lazily, so the next render downloads them again.
*
* @param int $comment_id The comment ID.
*
* @return void
*/
public static function clear_cached_images( $comment_id ) {
self::invalidate_entity( $comment_id );
}
}
72 changes: 5 additions & 67 deletions includes/cache/class-media.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,11 @@
/**
* Media cache class.
*
* Handles lazy caching of remote post and comment media locally.
* Handles lazy caching of remote post media locally.
* Media is cached on-demand when URLs pass through the `activitypub_remote_media_url` filter.
*
* Storage locations:
* - Posts: /wp-content/uploads/activitypub/posts/{post_id}/
* - Comments: /wp-content/uploads/activitypub/comments/{comment_id}/
*
* Files are cleaned up automatically when the parent post is deleted.
* Media is stored in /wp-content/uploads/activitypub/posts/{post_id}/
* and cleaned up automatically when the post is deleted.
*
* @since 5.6.0
*/
Expand All @@ -38,32 +35,13 @@ class Media extends File {
*/
const CONTEXT = 'media';

/**
* Context identifier for comment media.
*
* Reserved for future use when comment media caching is implemented.
* Currently, only post media caching is active via maybe_cache().
*
* @var string
*/
const CONTEXT_COMMENT = 'comment_media';

/**
* Base directory for post media.
*
* @var string
*/
const BASE_DIR_POSTS = '/activitypub/posts/';

/**
* Base directory for comment media.
*
* Reserved for future use when comment media caching is implemented.
*
* @var string
*/
const BASE_DIR_COMMENTS = '/activitypub/comments/';

/**
* Get the cache type identifier.
*
Expand All @@ -76,9 +54,6 @@ public static function get_type() {
/**
* Get the base directory path relative to uploads.
*
* Default to post media directory. Use get_storage_paths_for_context()
* for context-aware path resolution.
*
* @return string Base directory path.
*/
public static function get_base_dir() {
Expand All @@ -103,30 +78,6 @@ public static function get_max_dimension() {
return self::MAX_DIMENSION;
}

/**
* Get storage paths based on context.
*
* @param string|int $entity_id The entity identifier.
* @param string $context The context ('media' or 'comment_media').
*
* @return array {
* Storage paths for the entity.
*
* @type string $basedir Base directory path.
* @type string $baseurl Base URL.
* }
*/
public static function get_storage_paths_for_context( $entity_id, $context = self::CONTEXT ) {
$upload_dir = \wp_upload_dir();
$entity_id = \sanitize_file_name( (string) $entity_id );
$base_dir = self::CONTEXT_COMMENT === $context ? self::BASE_DIR_COMMENTS : self::BASE_DIR_POSTS;

return array(
'basedir' => $upload_dir['basedir'] . $base_dir . $entity_id,
'baseurl' => $upload_dir['baseurl'] . $base_dir . $entity_id,
);
}

/**
* Initialize the cache handler.
*
Expand Down Expand Up @@ -156,11 +107,11 @@ public static function init() {
* @return string The local URL if cached successfully, otherwise the original URL.
*/
public static function maybe_cache( $url, $context, $entity_id = null, $options = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for filter signature.
if ( self::CONTEXT !== $context || empty( $url ) || empty( $entity_id ) ) {
if ( static::get_context() !== $context || empty( $url ) || empty( $entity_id ) ) {
return $url;
}

$cached_url = self::get_or_cache( $url, $entity_id );
$cached_url = static::get_or_cache( $url, $entity_id );

return $cached_url ?: $url;
}
Expand All @@ -179,17 +130,4 @@ public static function maybe_cleanup( $post_id ) {

self::invalidate_entity( $post_id );
}

/**
* Invalidate cached media for a comment.
*
* @param int $comment_id The comment ID.
*
* @return bool True on success, false on failure.
*/
public static function invalidate_comment( $comment_id ) {
$paths = self::get_storage_paths_for_context( $comment_id, self::CONTEXT_COMMENT );

return static::delete_directory( $paths['basedir'] );
}
}
Loading
Loading