diff --git a/src/main/java/net/javahippie/fitpub/controller/CommentController.java b/src/main/java/net/javahippie/fitpub/controller/CommentController.java index b54d349..c2f88bc 100644 --- a/src/main/java/net/javahippie/fitpub/controller/CommentController.java +++ b/src/main/java/net/javahippie/fitpub/controller/CommentController.java @@ -201,13 +201,27 @@ public class CommentController { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - // Soft delete + // Soft delete locally comment.setDeleted(true); commentRepository.save(comment); log.info("User {} deleted comment {}", user.getUsername(), commentId); - // TODO: Send ActivityPub Delete activity to followers if activity is public + // Federate the deletion to remote followers so they tombstone their cached + // copy of the Note. The visibility check mirrors createComment(): the + // original Create was only sent if the parent activity was PUBLIC or + // FOLLOWERS, so the Delete needs to follow the same audience rule. The + // commentUri must match exactly what was used in the original Create + // activity, otherwise remote servers won't be able to match the tombstone + // to the cached note. + Activity activity = activityRepository.findById(activityId).orElse(null); + if (activity != null + && (activity.getVisibility() == Activity.Visibility.PUBLIC + || activity.getVisibility() == Activity.Visibility.FOLLOWERS)) { + String commentUri = baseUrl + "/activities/" + activityId + "/comments/" + commentId; + federationService.sendDeleteActivity(commentUri, user); + log.info("Sent Delete federation for comment {} on activity {}", commentId, activityId); + } return ResponseEntity.noContent().build(); } diff --git a/src/main/java/net/javahippie/fitpub/service/FederationService.java b/src/main/java/net/javahippie/fitpub/service/FederationService.java index ff41cf4..8a1efea 100644 --- a/src/main/java/net/javahippie/fitpub/service/FederationService.java +++ b/src/main/java/net/javahippie/fitpub/service/FederationService.java @@ -311,10 +311,16 @@ public class FederationService { } /** - * Get all follower inbox URLs for a local user. + * Get the inbox URLs of all remote followers of a local user. + * + *

Local followers are deliberately skipped: they live on the same server and + * see new activities via the local timeline queries, so there is nothing to + * federate to them. Without this filter, every call would attempt to + * {@code fetchRemoteActor(null)} for each local follower row, log a stack trace + * at ERROR level, and then drop the resulting null from the inbox list. * * @param userId the local user's ID - * @return list of inbox URLs + * @return list of remote follower inbox URLs (deduplicated, shared inbox preferred) */ @Transactional(readOnly = true) public List getFollowerInboxes(UUID userId) { @@ -325,6 +331,7 @@ public class FederationService { List followers = followRepository.findAcceptedFollowersByActorUri(actorUri); return followers.stream() + .filter(follow -> follow.getRemoteActorUri() != null) // skip local followers (no federation needed) .map(follow -> { try { RemoteActor actor = remoteActorRepository.findByActorUri(follow.getRemoteActorUri()) @@ -529,9 +536,14 @@ public class FederationService { /** * Send a Delete activity to notify followers that an object has been deleted. * - * @param objectUri the URI of the deleted object (e.g., activity URI) + *

Runs on the {@code taskExecutor} pool. Used for both activity deletes and + * comment (Note) deletes — the user-facing HTTP response shouldn't wait on the + * federation fanout to remote follower inboxes. + * + * @param objectUri the URI of the deleted object (e.g., activity URI or comment Note URI) * @param sender the user who deleted the object */ + @Async("taskExecutor") public void sendDeleteActivity(String objectUri, User sender) { try { String deleteId = baseUrl + "/activities/delete/" + UUID.randomUUID();