More federation
This commit is contained in:
parent
d42f9b5339
commit
c61cc2950c
3 changed files with 269 additions and 7 deletions
|
|
@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an activity by ID.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue