More federation

This commit is contained in:
Tim Zöller 2025-11-30 10:44:34 +01:00
parent d42f9b5339
commit c61cc2950c
3 changed files with 269 additions and 7 deletions

View file

@ -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<String, Object> 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("<h3>").append(escapeHtml(activity.getTitle())).append("</h3>");
}
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
content.append("<p>").append(escapeHtml(activity.getDescription())).append("</p>");
}
content.append("<p>");
content.append("<strong>Activity Type:</strong> ").append(activity.getActivityType()).append("<br>");
if (activity.getTotalDistance() != null) {
content.append("<strong>Distance:</strong> ")
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0))
.append("<br>");
}
if (activity.getTotalDurationSeconds() != null) {
long hours = activity.getTotalDurationSeconds() / 3600;
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
long seconds = activity.getTotalDurationSeconds() % 60;
content.append("<strong>Duration:</strong> ");
if (hours > 0) {
content.append(hours).append("h ");
}
content.append(minutes).append("m ").append(seconds).append("s<br>");
}
if (activity.getElevationGain() != null) {
content.append("<strong>Elevation Gain:</strong> ")
.append(String.format("%.0f m", activity.getElevationGain()))
.append("<br>");
}
content.append("</p>");
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
/**
* Retrieves an activity by ID.
*

View file

@ -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<String, Object> 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();
}

View file

@ -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<String, Object> object, User sender, boolean toPublic) {
try {
String createId = baseUrl + "/activities/create/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + sender.getUsername();
Map<String, Object> 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<String> 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<String, Object> 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<String> 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<String, Object> originalActivity, User sender) {
try {
String undoId = baseUrl + "/activities/undo/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + sender.getUsername();
Map<String, Object> 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<String> 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<String, Object> actorData) {