Moar federation
This commit is contained in:
parent
7ba5697e4f
commit
71aa6ffffe
5 changed files with 181 additions and 82 deletions
|
|
@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&");
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue