From 71aa6ffffee771c0cc96926c1c0d221c15a7e62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Mon, 1 Dec 2025 09:52:50 +0100 Subject: [PATCH] Moar federation --- .../fitpub/controller/CommentController.java | 51 ++++++++- .../fitpub/service/InboxProcessor.java | 105 +++++++++++++++++- src/main/resources/static/js/timeline.js | 32 ++---- .../templates/activities/detail.html | 50 +++------ .../resources/templates/activities/list.html | 25 +---- 5 files changed, 181 insertions(+), 82 deletions(-) diff --git a/src/main/java/org/operaton/fitpub/controller/CommentController.java b/src/main/java/org/operaton/fitpub/controller/CommentController.java index 41c0775..705e465 100644 --- a/src/main/java/org/operaton/fitpub/controller/CommentController.java +++ b/src/main/java/org/operaton/fitpub/controller/CommentController.java @@ -11,6 +11,7 @@ import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.ActivityRepository; import org.operaton.fitpub.repository.CommentRepository; import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.service.FederationService; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -23,6 +24,9 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.UUID; /** @@ -37,6 +41,7 @@ public class CommentController { private final CommentRepository commentRepository; private final ActivityRepository activityRepository; private final UserRepository userRepository; + private final FederationService federationService; @Value("${fitpub.base-url}") private String baseUrl; @@ -124,7 +129,36 @@ public class CommentController { log.info("User {} commented on activity {}", user.getUsername(), activityId); - // TODO: Send ActivityPub Create/Note activity to followers if activity is public + // Send ActivityPub Create/Note activity to followers if activity is public + if (activity.getVisibility() == Activity.Visibility.PUBLIC || + activity.getVisibility() == Activity.Visibility.FOLLOWERS) { + + String commentUri = baseUrl + "/activities/" + activityId + "/comments/" + saved.getId(); + String activityUri = baseUrl + "/activities/" + activityId; + String actorUri = baseUrl + "/users/" + user.getUsername(); + + // Create Note object for the comment + Map noteObject = new HashMap<>(); + noteObject.put("id", commentUri); + noteObject.put("type", "Note"); + noteObject.put("attributedTo", actorUri); + noteObject.put("inReplyTo", activityUri); + noteObject.put("content", escapeHtml(saved.getContent())); + noteObject.put("published", saved.getCreatedAt().toString()); + + 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")); + } + + // Send Create activity + federationService.sendCreateActivity(commentUri, noteObject, user, + activity.getVisibility() == Activity.Visibility.PUBLIC); + + log.info("Sent comment federation for comment {} on activity {}", saved.getId(), activityId); + } return ResponseEntity.status(HttpStatus.CREATED) .body(CommentDTO.fromEntity(saved, baseUrl, user.getId())); @@ -170,4 +204,19 @@ public class CommentController { return ResponseEntity.noContent().build(); } + + /** + * Escape HTML entities in text. + */ + private String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } } diff --git a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java index 692169c..09510a2 100644 --- a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java +++ b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java @@ -3,11 +3,13 @@ package org.operaton.fitpub.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.Comment; import org.operaton.fitpub.model.entity.Follow; import org.operaton.fitpub.model.entity.Like; import org.operaton.fitpub.model.entity.RemoteActor; import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.ActivityRepository; +import org.operaton.fitpub.repository.CommentRepository; import org.operaton.fitpub.repository.FollowRepository; import org.operaton.fitpub.repository.LikeRepository; import org.operaton.fitpub.repository.UserRepository; @@ -31,6 +33,7 @@ public class InboxProcessor { private final FederationService federationService; private final ActivityRepository activityRepository; private final LikeRepository likeRepository; + private final CommentRepository commentRepository; @Value("${fitpub.base-url}") private String baseUrl; @@ -177,11 +180,81 @@ public class InboxProcessor { } /** - * Process a Create activity (e.g., new post). + * Process a Create activity (e.g., new post, comment). */ private void processCreate(String username, Map activity) { - // TODO: Implement Create activity processing - log.debug("Received Create activity for user {}", username); + try { + String actor = (String) activity.get("actor"); + Object object = activity.get("object"); + + if (!(object instanceof Map)) { + log.warn("Create activity object is not a Map"); + return; + } + + @SuppressWarnings("unchecked") + Map noteObject = (Map) object; + String type = (String) noteObject.get("type"); + + if (!"Note".equals(type)) { + log.debug("Received Create activity with non-Note object type: {}", type); + return; + } + + String inReplyTo = (String) noteObject.get("inReplyTo"); + if (inReplyTo == null) { + log.debug("Create/Note is not a reply, ignoring"); + return; + } + + // Extract activity ID from inReplyTo URI + UUID activityId = extractActivityIdFromUri(inReplyTo); + if (activityId == null) { + log.warn("Could not extract activity ID from inReplyTo: {}", inReplyTo); + return; + } + + // Check if activity exists + Activity localActivity = activityRepository.findById(activityId).orElse(null); + if (localActivity == null) { + log.warn("Activity not found: {}", activityId); + return; + } + + // Fetch remote actor information + RemoteActor remoteActor = federationService.fetchRemoteActor(actor); + + // Get comment content + String content = (String) noteObject.get("content"); + if (content == null || content.trim().isEmpty()) { + log.warn("Create/Note has no content"); + return; + } + + // Check if comment already exists by activityPubId + String commentId = (String) noteObject.get("id"); + if (commentRepository.findByActivityPubId(commentId).isPresent()) { + log.debug("Comment already exists with activityPubId: {}", commentId); + return; + } + + // Create comment + Comment comment = Comment.builder() + .activityId(activityId) + .userId(null) // Remote actor, not a local user + .remoteActorUri(actor) + .displayName(remoteActor.getDisplayName() != null ? remoteActor.getDisplayName() : remoteActor.getUsername()) + .avatarUrl(remoteActor.getAvatarUrl()) + .content(stripHtml(content)) + .activityPubId(commentId) + .build(); + + commentRepository.save(comment); + log.info("Processed Create/Note (comment) from {} for activity {}", actor, activityId); + + } catch (Exception e) { + log.error("Error processing Create activity", e); + } } /** @@ -251,4 +324,30 @@ public class InboxProcessor { return null; } } + + /** + * Strip HTML tags from content. + * Mastodon sends HTML formatted content, we want plain text. + */ + private String stripHtml(String html) { + if (html == null) { + return ""; + } + // Replace common HTML tags with appropriate text + String text = html + .replaceAll("", "\n") + .replaceAll("

", "") + .replaceAll("

", "\n") + .replaceAll("<[^>]+>", ""); // Remove all other HTML tags + + // Decode HTML entities + text = text + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"); + + return text.trim(); + } } diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index d0c5830..c536843 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -147,31 +147,13 @@ const FitPubTimeline = { } -
-
-
-
${this.formatDistance(activity.totalDistance)}
-
Distance
-
-
-
-
-
${this.formatDuration(activity.totalDurationSeconds)}
-
Duration
-
-
-
-
-
${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)}
-
Avg Pace
-
-
-
-
-
${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
-
Elevation
-
-
+
+ + Distance: ${this.formatDistance(activity.totalDistance)} • + Duration: ${this.formatDuration(activity.totalDurationSeconds)} • + Pace: ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} • + Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'} +
diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 50c9d4e..9a9802b 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -58,39 +58,23 @@
-
-
-
-
-

--

-

Distance

-
-
-
-
-
-
-

--

-

Duration

-
-
-
-
-
-
-

--

-

Elevation Gain

-
-
-
-
-
-
-

--

-

Avg Pace

-
-
-
+
+ + + + + + + + + + + + + + + +
Distance--Duration--
Elevation--Avg Pace--
diff --git a/src/main/resources/templates/activities/list.html b/src/main/resources/templates/activities/list.html index 18e8bc1..d517c50 100644 --- a/src/main/resources/templates/activities/list.html +++ b/src/main/resources/templates/activities/list.html @@ -172,26 +172,11 @@ ${activity.description ? `

${escapeHtml(activity.description).substring(0, 150)}${activity.description.length > 150 ? '...' : ''}

` : ''}
-
-
-
-
${formatDistance(activity.totalDistance)}
-
Distance
-
-
-
-
-
${formatDuration(activity.totalDuration)}
-
Time
-
-
-
-
-
${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
-
Elevation
-
-
-
+ + Distance: ${formatDistance(activity.totalDistance)}
+ Duration: ${formatDuration(activity.totalDuration)}
+ Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'} +