From c61cc2950c74b36c1cbc9cbbf2dd51ac17fb3472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Sun, 30 Nov 2025 10:44:34 +0100 Subject: [PATCH] More federation --- .../fitpub/controller/ActivityController.java | 132 +++++++++++++++++- .../fitpub/controller/LikeController.java | 29 +++- .../fitpub/service/FederationService.java | 115 ++++++++++++++- 3 files changed, 269 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java index 0260683..a552d35 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -9,7 +9,9 @@ import org.operaton.fitpub.model.dto.ActivityUploadRequest; import org.operaton.fitpub.model.entity.Activity; import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.service.FederationService; import org.operaton.fitpub.service.FitFileService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -18,7 +20,10 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.time.Instant; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -34,6 +39,10 @@ public class ActivityController { private final FitFileService fitFileService; private final UserRepository userRepository; + private final FederationService federationService; + + @Value("${fitpub.base-url}") + private String baseUrl; /** * Helper method to get user ID from authenticated UserDetails. @@ -64,20 +73,139 @@ public class ActivityController { ) { log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename()); - UUID userId = getUserId(userDetails); + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); Activity activity = fitFileService.processFitFile( file, - userId, + user.getId(), request.getTitle(), request.getDescription(), request.getVisibility() ); + // Send ActivityPub Create activity to followers if public or followers-only + if (activity.getVisibility() == Activity.Visibility.PUBLIC || + activity.getVisibility() == Activity.Visibility.FOLLOWERS) { + + String activityUri = baseUrl + "/activities/" + activity.getId(); + String actorUri = baseUrl + "/users/" + user.getUsername(); + + // Create the Note object representing the activity + Map noteObject = new HashMap<>(); + noteObject.put("id", activityUri); + noteObject.put("type", "Note"); + noteObject.put("attributedTo", actorUri); + noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("content", formatActivityContent(activity)); + + if (activity.getVisibility() == Activity.Visibility.PUBLIC) { + noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); + noteObject.put("cc", List.of(actorUri + "/followers")); + } else { + noteObject.put("to", List.of(actorUri + "/followers")); + } + + // Add summary with key metrics + String summary = formatActivitySummary(activity); + noteObject.put("summary", summary); + + // Add URL to the activity page + noteObject.put("url", baseUrl + "/activities/" + activity.getId()); + + federationService.sendCreateActivity( + activityUri, + noteObject, + user, + activity.getVisibility() == Activity.Visibility.PUBLIC + ); + } + ActivityDTO dto = ActivityDTO.fromEntity(activity); return ResponseEntity.status(HttpStatus.CREATED).body(dto); } + /** + * Format activity content for ActivityPub. + */ + private String formatActivityContent(Activity activity) { + StringBuilder content = new StringBuilder(); + + if (activity.getTitle() != null && !activity.getTitle().isEmpty()) { + content.append("

").append(escapeHtml(activity.getTitle())).append("

"); + } + + if (activity.getDescription() != null && !activity.getDescription().isEmpty()) { + content.append("

").append(escapeHtml(activity.getDescription())).append("

"); + } + + content.append("

"); + content.append("Activity Type: ").append(activity.getActivityType()).append("
"); + + if (activity.getTotalDistance() != null) { + content.append("Distance: ") + .append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0)) + .append("
"); + } + + if (activity.getTotalDurationSeconds() != null) { + long hours = activity.getTotalDurationSeconds() / 3600; + long minutes = (activity.getTotalDurationSeconds() % 3600) / 60; + long seconds = activity.getTotalDurationSeconds() % 60; + content.append("Duration: "); + if (hours > 0) { + content.append(hours).append("h "); + } + content.append(minutes).append("m ").append(seconds).append("s
"); + } + + if (activity.getElevationGain() != null) { + content.append("Elevation Gain: ") + .append(String.format("%.0f m", activity.getElevationGain())) + .append("
"); + } + + content.append("

"); + + return content.toString(); + } + + /** + * Format activity summary for ActivityPub. + */ + private String formatActivitySummary(Activity activity) { + StringBuilder summary = new StringBuilder(); + summary.append(activity.getActivityType()); + + if (activity.getTotalDistance() != null) { + summary.append(" • ").append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0)); + } + + if (activity.getTotalDurationSeconds() != null) { + long hours = activity.getTotalDurationSeconds() / 3600; + long minutes = (activity.getTotalDurationSeconds() % 3600) / 60; + if (hours > 0) { + summary.append(" • ").append(hours).append("h ").append(minutes).append("m"); + } else { + summary.append(" • ").append(minutes).append("m"); + } + } + + return summary.toString(); + } + + /** + * Simple HTML escaping. + */ + private String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + /** * Retrieves an activity by ID. * diff --git a/src/main/java/org/operaton/fitpub/controller/LikeController.java b/src/main/java/org/operaton/fitpub/controller/LikeController.java index a7573ce..3d920dd 100644 --- a/src/main/java/org/operaton/fitpub/controller/LikeController.java +++ b/src/main/java/org/operaton/fitpub/controller/LikeController.java @@ -9,6 +9,7 @@ import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.ActivityRepository; import org.operaton.fitpub.repository.LikeRepository; import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.service.FederationService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -18,7 +19,10 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.time.Instant; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -34,6 +38,7 @@ public class LikeController { private final LikeRepository likeRepository; private final ActivityRepository activityRepository; private final UserRepository userRepository; + private final FederationService federationService; @Value("${fitpub.base-url}") private String baseUrl; @@ -106,7 +111,11 @@ public class LikeController { log.info("User {} liked activity {}", user.getUsername(), activityId); - // TODO: Send ActivityPub Like activity to followers if activity is public + // Send ActivityPub Like activity to followers if activity is public + if (activity.getVisibility() == Activity.Visibility.PUBLIC) { + String activityUri = baseUrl + "/activities/" + activityId; + federationService.sendLikeActivity(activityUri, user); + } return ResponseEntity.status(HttpStatus.CREATED) .body(LikeDTO.fromEntity(saved, baseUrl)); @@ -132,11 +141,27 @@ public class LikeController { return ResponseEntity.notFound().build(); } + // Get activity for visibility check + Activity activity = activityRepository.findById(activityId).orElse(null); + likeRepository.deleteByActivityIdAndUserId(activityId, user.getId()); log.info("User {} unliked activity {}", user.getUsername(), activityId); - // TODO: Send ActivityPub Undo Like activity to followers if activity is public + // Send ActivityPub Undo Like activity to followers if activity is public + if (activity != null && activity.getVisibility() == Activity.Visibility.PUBLIC) { + String activityUri = baseUrl + "/activities/" + activityId; + String likeId = baseUrl + "/activities/like/" + UUID.randomUUID(); + String actorUri = baseUrl + "/users/" + user.getUsername(); + + Map likeActivity = new HashMap<>(); + likeActivity.put("type", "Like"); + likeActivity.put("id", likeId); + likeActivity.put("actor", actorUri); + likeActivity.put("object", activityUri); + + federationService.sendUndoActivity(likeId, likeActivity, user); + } return ResponseEntity.noContent().build(); } diff --git a/src/main/java/org/operaton/fitpub/service/FederationService.java b/src/main/java/org/operaton/fitpub/service/FederationService.java index 54510ea..b8ed2d5 100644 --- a/src/main/java/org/operaton/fitpub/service/FederationService.java +++ b/src/main/java/org/operaton/fitpub/service/FederationService.java @@ -213,11 +213,11 @@ public class FederationService { return followers.stream() .map(follow -> { try { - RemoteActor actor = remoteActorRepository.findByActorUri(follow.getFollowingActorUri()) - .orElseGet(() -> fetchRemoteActor(follow.getFollowingActorUri())); + RemoteActor actor = remoteActorRepository.findByActorUri(follow.getRemoteActorUri()) + .orElseGet(() -> fetchRemoteActor(follow.getRemoteActorUri())); return actor.getSharedInboxUrl() != null ? actor.getSharedInboxUrl() : actor.getInboxUrl(); } catch (Exception e) { - log.error("Failed to get inbox for follower: {}", follow.getFollowingActorUri(), e); + log.error("Failed to get inbox for follower: {}", follow.getRemoteActorUri(), e); return null; } }) @@ -226,6 +226,115 @@ public class FederationService { .toList(); } + /** + * Send a Create activity for a new post/object. + * + * @param objectId the ID of the created object + * @param object the object being created (activity, note, etc.) + * @param sender the local user creating the object + * @param toPublic whether to send to public (CC followers) + */ + @Transactional + public void sendCreateActivity(String objectId, Map object, User sender, boolean toPublic) { + try { + String createId = baseUrl + "/activities/create/" + UUID.randomUUID(); + String actorUri = baseUrl + "/users/" + sender.getUsername(); + + Map createActivity = new HashMap<>(); + createActivity.put("@context", "https://www.w3.org/ns/activitystreams"); + createActivity.put("type", "Create"); + createActivity.put("id", createId); + createActivity.put("actor", actorUri); + createActivity.put("published", Instant.now().toString()); + createActivity.put("object", object); + + if (toPublic) { + createActivity.put("to", List.of("https://www.w3.org/ns/activitystreams#Public")); + createActivity.put("cc", List.of(actorUri + "/followers")); + } else { + createActivity.put("to", List.of(actorUri + "/followers")); + } + + // Send to all follower inboxes + List inboxes = getFollowerInboxes(sender.getId()); + for (String inbox : inboxes) { + sendActivity(inbox, createActivity, sender); + } + + log.info("Sent Create activity for object: {} to {} inboxes", objectId, inboxes.size()); + + } catch (Exception e) { + log.error("Failed to send Create activity for object: {}", objectId, e); + } + } + + /** + * Send a Like activity. + * + * @param objectUri the URI of the object being liked + * @param sender the local user liking the object + */ + @Transactional + public void sendLikeActivity(String objectUri, User sender) { + try { + String likeId = baseUrl + "/activities/like/" + UUID.randomUUID(); + String actorUri = baseUrl + "/users/" + sender.getUsername(); + + Map likeActivity = new HashMap<>(); + likeActivity.put("@context", "https://www.w3.org/ns/activitystreams"); + likeActivity.put("type", "Like"); + likeActivity.put("id", likeId); + likeActivity.put("actor", actorUri); + likeActivity.put("object", objectUri); + likeActivity.put("published", Instant.now().toString()); + + // Send to all follower inboxes + List inboxes = getFollowerInboxes(sender.getId()); + for (String inbox : inboxes) { + sendActivity(inbox, likeActivity, sender); + } + + log.info("Sent Like activity for object: {} to {} inboxes", objectUri, inboxes.size()); + + } catch (Exception e) { + log.error("Failed to send Like activity for object: {}", objectUri, e); + } + } + + /** + * Send an Undo activity (for unlike, unfollow, etc.). + * + * @param originalActivityId the ID of the activity being undone + * @param originalActivity the original activity being undone + * @param sender the local user undoing the activity + */ + @Transactional + public void sendUndoActivity(String originalActivityId, Map originalActivity, User sender) { + try { + String undoId = baseUrl + "/activities/undo/" + UUID.randomUUID(); + String actorUri = baseUrl + "/users/" + sender.getUsername(); + + Map undoActivity = new HashMap<>(); + undoActivity.put("@context", "https://www.w3.org/ns/activitystreams"); + undoActivity.put("type", "Undo"); + undoActivity.put("id", undoId); + undoActivity.put("actor", actorUri); + undoActivity.put("object", originalActivity); + undoActivity.put("published", Instant.now().toString()); + + // Send to all follower inboxes + List inboxes = getFollowerInboxes(sender.getId()); + for (String inbox : inboxes) { + sendActivity(inbox, undoActivity, sender); + } + + log.info("Sent Undo activity for: {} to {} inboxes", originalActivityId, inboxes.size()); + + } catch (Exception e) { + log.error("Failed to send Undo activity for: {}", originalActivityId, e); + } + } + // Helper methods private String extractUsername(String actorUri, Map actorData) {