Moar federation

This commit is contained in:
Tim Zöller 2025-12-01 09:52:50 +01:00
parent 7ba5697e4f
commit 71aa6ffffe
5 changed files with 181 additions and 82 deletions

View file

@ -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<String, Object> 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}

View file

@ -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<String, Object> 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<String, Object> noteObject = (Map<String, Object>) 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("<br\\s*/?>", "\n")
.replaceAll("<p>", "")
.replaceAll("</p>", "\n")
.replaceAll("<[^>]+>", ""); // Remove all other HTML tags
// Decode HTML entities
text = text
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&");
return text.trim();
}
}

View file

@ -147,31 +147,13 @@ const FitPubTimeline = {
}
<!-- Activity Metrics -->
<div class="row text-center mb-3">
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatDistance(activity.totalDistance)}</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatDuration(activity.totalDurationSeconds)}</div>
<div class="metric-label">Duration</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)}</div>
<div class="metric-label">Avg Pace</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}</div>
<div class="metric-label">Elevation</div>
</div>
</div>
<div class="mb-2">
<small class="text-muted">
<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)}
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)}
<strong>Pace:</strong> ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)}
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
</small>
</div>
<!-- Preview Map -->

View file

@ -58,39 +58,23 @@
</div>
<!-- Activity Metrics -->
<div class="row mb-4">
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricDistance">--</h3>
<p class="text-muted mb-0">Distance</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricDuration">--</h3>
<p class="text-muted mb-0">Duration</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricElevation">--</h3>
<p class="text-muted mb-0">Elevation Gain</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricPace">--</h3>
<p class="text-muted mb-0">Avg Pace</p>
</div>
</div>
</div>
<div class="mb-3">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 25%;">Distance</td>
<td class="fw-bold" id="metricDistance">--</td>
<td class="text-muted" style="width: 25%;">Duration</td>
<td class="fw-bold" id="metricDuration">--</td>
</tr>
<tr>
<td class="text-muted">Elevation</td>
<td class="fw-bold" id="metricElevation">--</td>
<td class="text-muted">Avg Pace</td>
<td class="fw-bold" id="metricPace">--</td>
</tr>
</tbody>
</table>
</div>
<!-- Map -->

View file

@ -172,26 +172,11 @@
${activity.description ? `<p class="card-text">${escapeHtml(activity.description).substring(0, 150)}${activity.description.length > 150 ? '...' : ''}</p>` : ''}
</div>
<div class="col-md-4">
<div class="row text-center">
<div class="col-4">
<div class="metric-card">
<div class="metric-value small">${formatDistance(activity.totalDistance)}</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-4">
<div class="metric-card">
<div class="metric-value small">${formatDuration(activity.totalDuration)}</div>
<div class="metric-label">Time</div>
</div>
</div>
<div class="col-4">
<div class="metric-card">
<div class="metric-value small">${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}</div>
<div class="metric-label">Elevation</div>
</div>
</div>
</div>
<small class="text-muted">
<strong>Distance:</strong> ${formatDistance(activity.totalDistance)}<br>
<strong>Duration:</strong> ${formatDuration(activity.totalDuration)}<br>
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
</small>
</div>
</div>
<div class="mt-3 d-flex gap-2">