Better Federation Support

This commit is contained in:
Tim Zöller 2025-12-15 21:55:17 +01:00
parent 15b420b87a
commit 5b687883b0
22 changed files with 2931 additions and 49 deletions

View file

@ -121,6 +121,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users
.requestMatchers(HttpMethod.GET, "/api/users/discover-remote").authenticated() // Remote user discovery
.requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check

View file

@ -39,6 +39,8 @@ public class UserController {
private final UserRepository userRepository;
private final FollowRepository followRepository;
private final RemoteActorRepository remoteActorRepository;
private final org.operaton.fitpub.service.WebFingerClient webFingerClient;
private final org.operaton.fitpub.service.FederationService federationService;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -198,6 +200,44 @@ public class UserController {
return ResponseEntity.ok(userDTOs);
}
/**
* Discover a remote user via WebFinger.
* Takes a handle in the format @username@domain or username@domain,
* performs WebFinger discovery, fetches the remote actor, and returns actor information.
*
* @param handle the handle of the remote user (@username@domain)
* @param userDetails the authenticated user making the request
* @return ActorDTO containing remote user information
*/
@GetMapping("/discover-remote")
public ResponseEntity<ActorDTO> discoverRemoteUser(
@RequestParam String handle,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} discovering remote user: {}", userDetails.getUsername(), handle);
try {
// 1. WebFinger lookup to discover actor URI
String actorUri = webFingerClient.discoverActor(handle);
log.debug("Discovered actor URI: {}", actorUri);
// 2. Fetch remote actor information
RemoteActor remoteActor = federationService.fetchRemoteActor(actorUri);
log.debug("Fetched remote actor: {}", remoteActor.getUsername());
// 3. Convert to DTO and return (no follow relationship yet, so followedAt is null)
ActorDTO dto = ActorDTO.fromRemoteActor(remoteActor, null);
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException e) {
log.error("Invalid handle format: {}", handle, e);
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error discovering remote user: {}", handle, e);
return ResponseEntity.status(500).build();
}
}
/**
* Get list of followers for a user.
*
@ -275,9 +315,9 @@ public class UserController {
}
/**
* Follow a user.
* Follow a user (local or remote).
*
* @param username the username to follow
* @param username the username to follow (local username or @username@domain format)
* @param userDetails the authenticated user
* @return success response
*/
@ -292,6 +332,22 @@ public class UserController {
User currentUser = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("Current user not found"));
// Check if this is a remote user (contains @ and position > 0)
boolean isRemoteUser = username.contains("@") && username.indexOf("@") > 0;
if (isRemoteUser) {
// Remote user follow
return followRemoteUser(username, currentUser);
} else {
// Local user follow
return followLocalUser(username, currentUser);
}
}
/**
* Follow a local user (auto-accepted).
*/
private ResponseEntity<Map<String, Object>> followLocalUser(String username, User currentUser) {
// Get the user to follow
User userToFollow = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
@ -342,6 +398,56 @@ public class UserController {
));
}
/**
* Follow a remote user via ActivityPub (requires Accept from remote).
*/
private ResponseEntity<Map<String, Object>> followRemoteUser(String handle, User currentUser) {
try {
log.info("Following remote user: {}", handle);
// 1. Discover remote actor using WebFinger
String remoteActorUri = webFingerClient.discoverActor(handle);
log.debug("Discovered remote actor URI: {}", remoteActorUri);
// 2. Check if already following
Optional<Follow> existingFollow = followRepository.findByFollowerIdAndFollowingActorUri(
currentUser.getId(), remoteActorUri
);
if (existingFollow.isPresent()) {
Follow follow = existingFollow.get();
if (follow.getStatus() == Follow.FollowStatus.ACCEPTED) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Already following this user"));
} else {
return ResponseEntity.ok(Map.of(
"message", "Follow request already pending for " + handle,
"status", "PENDING"
));
}
}
// 3. Send Follow activity to remote actor
// This will also create a PENDING follow record
federationService.sendFollowActivity(remoteActorUri, currentUser);
return ResponseEntity.ok(Map.of(
"message", "Follow request sent to " + handle,
"status", "PENDING",
"note", "Waiting for acceptance from remote user"
));
} catch (IllegalArgumentException e) {
log.warn("Invalid handle format: {}", handle, e);
return ResponseEntity.badRequest()
.body(Map.of("error", "Invalid handle format: " + e.getMessage()));
} catch (Exception e) {
log.error("Failed to follow remote user: {}", handle, e);
return ResponseEntity.status(500)
.body(Map.of("error", "Failed to follow remote user: " + e.getMessage()));
}
}
/**
* Unfollow a user.
*

View file

@ -0,0 +1,55 @@
package org.operaton.fitpub.model.activitypub;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Represents a link in a WebFinger response (RFC 7033).
*
* Example:
* {
* "rel": "self",
* "type": "application/activity+json",
* "href": "https://fitpub.example/users/username"
* }
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class WebFingerLink {
/**
* The relationship type (e.g., "self", "http://webfinger.net/rel/profile-page").
*/
private String rel;
/**
* The media type of the linked resource (e.g., "application/activity+json").
*/
private String type;
/**
* The URL of the linked resource.
*/
private String href;
/**
* Optional template for the link (used for some link types).
*/
private String template;
/**
* Optional titles for the link in different languages.
*/
private java.util.Map<String, String> titles;
/**
* Optional properties for the link.
*/
private java.util.Map<String, String> properties;
}

View file

@ -0,0 +1,60 @@
package org.operaton.fitpub.model.activitypub;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* Represents a WebFinger resource response (RFC 7033).
*
* Example:
* {
* "subject": "acct:username@domain.com",
* "aliases": ["https://domain.com/users/username"],
* "links": [
* {
* "rel": "self",
* "type": "application/activity+json",
* "href": "https://domain.com/users/username"
* },
* {
* "rel": "http://webfinger.net/rel/profile-page",
* "type": "text/html",
* "href": "https://domain.com/@username"
* }
* ]
* }
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class WebFingerResource {
/**
* The subject identifier (usually in acct: URI format).
* Example: "acct:username@domain.com"
*/
private String subject;
/**
* Alternative URIs for the same resource.
*/
private List<String> aliases;
/**
* Links to related resources.
*/
private List<WebFingerLink> links;
/**
* Optional properties (key-value pairs).
*/
private Map<String, String> properties;
}

View file

@ -5,8 +5,11 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.RemoteActivity;
import org.operaton.fitpub.model.entity.RemoteActor;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.UUID;
/**
@ -38,6 +41,10 @@ public class TimelineActivityDTO {
private String avatarUrl;
private boolean isLocal;
// Remote activity fields (only populated for federated activities)
private String activityUri; // Full ActivityPub URI (for remote activities)
private String mapImageUrl; // Map image URL (for remote activities)
// Social interaction counts
private Long likesCount;
private Long commentsCount;
@ -71,6 +78,45 @@ public class TimelineActivityDTO {
.build();
}
/**
* Convert RemoteActivity entity to timeline DTO.
* Used for displaying federated activities from other FitPub instances.
*/
public static TimelineActivityDTO fromRemoteActivity(RemoteActivity remote, RemoteActor actor) {
// Create metrics summary from remote activity fields
ActivityMetricsSummary metrics = ActivityMetricsSummary.builder()
.averageHeartRate(remote.getAverageHeartRate())
.averageSpeed(remote.getAverageSpeed())
.maxSpeed(remote.getMaxSpeed())
.averagePaceSeconds(remote.getAveragePaceSeconds())
.calories(remote.getCalories())
.build();
return TimelineActivityDTO.builder()
.id(remote.getId())
.activityType(remote.getActivityType() != null ? remote.getActivityType() : "UNKNOWN")
.title(remote.getTitle())
.description(remote.getDescription())
.startedAt(remote.getPublishedAt() != null
? LocalDateTime.ofInstant(remote.getPublishedAt(), ZoneId.systemDefault())
: null)
.endedAt(null) // Not available for remote activities
.totalDistance(remote.getTotalDistance() != null ? remote.getTotalDistance().doubleValue() : null)
.totalDurationSeconds(remote.getTotalDurationSeconds())
.elevationGain(remote.getElevationGain() != null ? remote.getElevationGain().doubleValue() : null)
.elevationLoss(null) // Not available for remote activities
.visibility(remote.getVisibility() != null ? remote.getVisibility().name() : "PUBLIC")
.createdAt(remote.getCreatedAt())
.username(actor.getUsername())
.displayName(actor.getDisplayName() != null ? actor.getDisplayName() : actor.getUsername())
.avatarUrl(actor.getAvatarUrl())
.isLocal(false)
.activityUri(remote.getActivityUri())
.mapImageUrl(remote.getMapImageUrl())
.metrics(metrics)
.build();
}
/**
* Summary of activity metrics for timeline display.
*/

View file

@ -0,0 +1,188 @@
package org.operaton.fitpub.model.entity;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entity representing a remote fitness activity from another FitPub instance or ActivityPub server.
*
* IMPORTANT: This entity stores METADATA ONLY - no full track data.
* Track visualization is done via remote map image URLs pointing to the origin server.
*/
@Entity
@Table(name = "remote_activities", indexes = {
@Index(name = "idx_remote_activity_uri", columnList = "activity_uri", unique = true),
@Index(name = "idx_remote_activity_actor", columnList = "remote_actor_uri"),
@Index(name = "idx_remote_activity_published", columnList = "published_at"),
@Index(name = "idx_remote_activity_visibility", columnList = "visibility")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RemoteActivity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
/**
* ActivityPub activity URI (globally unique identifier).
* Example: https://fitpub.example/activities/12345
*/
@Column(name = "activity_uri", nullable = false, unique = true, length = 512)
private String activityUri;
/**
* ActivityPub actor URI of the user who created this activity.
* Example: https://fitpub.example/users/alice
*/
@Column(name = "remote_actor_uri", nullable = false, length = 512)
private String remoteActorUri;
/**
* Type of activity (RUN, RIDE, HIKE, SWIM, etc.).
*/
@Column(name = "activity_type", length = 50)
private String activityType;
/**
* Activity title.
*/
@Column(name = "title", nullable = false, length = 500)
private String title;
/**
* Activity description/notes.
*/
@Column(name = "description", columnDefinition = "TEXT")
private String description;
/**
* When the activity was published (from ActivityPub).
*/
@Column(name = "published_at", nullable = false)
private Instant publishedAt;
/**
* Total distance in meters.
*/
@Column(name = "total_distance")
private Long totalDistance;
/**
* Total duration in seconds.
*/
@Column(name = "total_duration_seconds")
private Long totalDurationSeconds;
/**
* Total elevation gain in meters.
*/
@Column(name = "elevation_gain")
private Integer elevationGain;
/**
* Average pace in seconds per kilometer (for runs).
*/
@Column(name = "average_pace_seconds")
private Long averagePaceSeconds;
/**
* Average heart rate in BPM.
*/
@Column(name = "average_heart_rate")
private Integer averageHeartRate;
/**
* Maximum speed in km/h.
*/
@Column(name = "max_speed")
private Double maxSpeed;
/**
* Average speed in km/h.
*/
@Column(name = "average_speed")
private Double averageSpeed;
/**
* Calories burned (if provided).
*/
@Column(name = "calories")
private Integer calories;
/**
* URL to the map image on the remote server.
* This points to a static map image generated by the origin server.
*/
@Column(name = "map_image_url", length = 512)
private String mapImageUrl;
/**
* URL to the GeoJSON track data on the remote server (optional).
* If provided, allows dynamic map rendering.
*/
@Column(name = "track_geojson_url", length = 512)
private String trackGeojsonUrl;
/**
* Visibility level of the activity.
*/
@Enumerated(EnumType.STRING)
@Column(name = "visibility", nullable = false, length = 20)
private Visibility visibility;
/**
* Full ActivityPub object as JSON (for future re-parsing or debugging).
* Stored as JSONB for efficient querying.
*/
@Type(JsonBinaryType.class)
@Column(name = "activitypub_object", columnDefinition = "jsonb")
private String activityPubObject;
/**
* Timestamp when this record was created locally.
*/
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* Timestamp when this record was last updated.
*/
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/**
* Visibility levels for remote activities.
*/
public enum Visibility {
/**
* Public activity visible to everyone.
*/
PUBLIC,
/**
* Activity visible only to followers.
*/
FOLLOWERS,
/**
* Private activity (should not be federated, but included for completeness).
*/
PRIVATE
}
}

View file

@ -67,4 +67,14 @@ public interface FollowRepository extends JpaRepository<Follow, UUID> {
* @return the follow relationship if it exists
*/
Optional<Follow> findByActivityId(String activityId);
/**
* Find a follow relationship by remote actor URI and following actor URI.
* Used to check if a remote user is following a local user.
*
* @param remoteActorUri the remote actor's URI (follower)
* @param followingActorUri the actor URI being followed
* @return the follow relationship if it exists
*/
Optional<Follow> findByRemoteActorUriAndFollowingActorUri(String remoteActorUri, String followingActorUri);
}

View file

@ -0,0 +1,111 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.RemoteActivity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for RemoteActivity entity.
* Provides methods for querying remote fitness activities from federated instances.
*/
@Repository
public interface RemoteActivityRepository extends JpaRepository<RemoteActivity, UUID> {
/**
* Finds a remote activity by its ActivityPub URI.
* Used for deduplication and activity lookup.
*
* @param activityUri the ActivityPub activity URI
* @return optional remote activity
*/
Optional<RemoteActivity> findByActivityUri(String activityUri);
/**
* Checks if a remote activity exists by its ActivityPub URI.
* Used for deduplication before storing.
*
* @param activityUri the ActivityPub activity URI
* @return true if exists
*/
boolean existsByActivityUri(String activityUri);
/**
* Finds remote activities by a specific remote actor.
*
* @param remoteActorUri the remote actor URI
* @param pageable pagination parameters
* @return page of remote activities
*/
Page<RemoteActivity> findByRemoteActorUri(String remoteActorUri, Pageable pageable);
/**
* Finds remote activities from multiple actors with specific visibility levels.
* Used for federated timeline - shows activities from users you follow.
*
* @param actorUris list of remote actor URIs
* @param visibilities list of allowed visibility levels (PUBLIC, FOLLOWERS)
* @param pageable pagination parameters
* @return page of remote activities
*/
@Query("SELECT ra FROM RemoteActivity ra WHERE ra.remoteActorUri IN :actorUris " +
"AND ra.visibility IN :visibilities " +
"ORDER BY ra.publishedAt DESC")
Page<RemoteActivity> findByRemoteActorUriInAndVisibilityIn(
@Param("actorUris") List<String> actorUris,
@Param("visibilities") List<RemoteActivity.Visibility> visibilities,
Pageable pageable
);
/**
* Finds all public remote activities.
* Used for public timeline.
*
* @param pageable pagination parameters
* @return page of public remote activities
*/
@Query("SELECT ra FROM RemoteActivity ra WHERE ra.visibility = 'PUBLIC' " +
"ORDER BY ra.publishedAt DESC")
Page<RemoteActivity> findAllPublicActivities(Pageable pageable);
/**
* Counts remote activities from a specific actor.
*
* @param remoteActorUri the remote actor URI
* @return count of activities
*/
long countByRemoteActorUri(String remoteActorUri);
/**
* Finds activities by type from specific actors.
* Used for filtering by activity type.
*
* @param actorUris list of remote actor URIs
* @param activityType the activity type (RUN, RIDE, etc.)
* @param pageable pagination parameters
* @return page of activities
*/
@Query("SELECT ra FROM RemoteActivity ra WHERE ra.remoteActorUri IN :actorUris " +
"AND ra.activityType = :activityType " +
"ORDER BY ra.publishedAt DESC")
Page<RemoteActivity> findByRemoteActorUriInAndActivityType(
@Param("actorUris") List<String> actorUris,
@Param("activityType") String activityType,
Pageable pageable
);
/**
* Deletes all remote activities from a specific actor.
* Used when unfollowing or blocking a remote user.
*
* @param remoteActorUri the remote actor URI
*/
void deleteByRemoteActorUri(String remoteActorUri);
}

View file

@ -119,6 +119,53 @@ public class FederationService {
}
}
/**
* Send a Follow activity to a remote actor.
*
* @param remoteActorUri the URI of the remote actor to follow
* @param localUser the local user initiating the follow
*/
@Transactional
public void sendFollowActivity(String remoteActorUri, User localUser) {
try {
log.info("Sending Follow activity from {} to {}", localUser.getUsername(), remoteActorUri);
// 1. Fetch remote actor to get inbox URL and cache their info
RemoteActor remoteActor = fetchRemoteActor(remoteActorUri);
// 2. Create Follow activity
String followId = baseUrl + "/activities/follow/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + localUser.getUsername();
Map<String, Object> followActivity = new HashMap<>();
followActivity.put("@context", "https://www.w3.org/ns/activitystreams");
followActivity.put("type", "Follow");
followActivity.put("id", followId);
followActivity.put("actor", actorUri);
followActivity.put("object", remoteActorUri);
followActivity.put("published", Instant.now().toString());
// 3. Send to remote actor's inbox (HTTP-signed)
sendActivity(remoteActor.getInboxUrl(), followActivity, localUser);
// 4. Create local follow record with PENDING status
// The status will be updated to ACCEPTED when we receive an Accept activity
Follow follow = Follow.builder()
.followerId(localUser.getId())
.followingActorUri(remoteActorUri)
.status(Follow.FollowStatus.PENDING)
.activityId(followId)
.build();
followRepository.save(follow);
log.info("Follow activity sent successfully: {} -> {}", localUser.getUsername(), remoteActorUri);
} catch (Exception e) {
log.error("Failed to send Follow activity from {} to {}", localUser.getUsername(), remoteActorUri, e);
throw new RuntimeException("Failed to send Follow activity", e);
}
}
/**
* Send an Accept activity in response to a Follow.
*

View file

@ -17,6 +17,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@ -35,6 +36,8 @@ public class InboxProcessor {
private final LikeRepository likeRepository;
private final CommentRepository commentRepository;
private final NotificationService notificationService;
private final org.operaton.fitpub.repository.RemoteActivityRepository remoteActivityRepository;
private final org.operaton.fitpub.repository.RemoteActorRepository remoteActorRepository;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -166,16 +169,40 @@ public class InboxProcessor {
private void processAccept(String username, Map<String, Object> activity) {
try {
Object object = activity.get("object");
String activityId = null;
// Handle both embedded object (Map) and reference (String)
if (object instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> acceptObject = (Map<String, Object>) object;
String activityId = (String) acceptObject.get("id");
activityId = (String) acceptObject.get("id");
} else if (object instanceof String) {
activityId = (String) object;
}
if (activityId != null) {
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) {
// Update follow status to ACCEPTED
follow.setStatus(Follow.FollowStatus.ACCEPTED);
followRepository.save(follow);
log.info("Follow request accepted: {}", activityId);
// Create notification for the follower
// The follower is the local user who initiated the follow request
UUID followerId = follow.getFollowerId();
if (followerId != null) {
User follower = userRepository.findById(followerId).orElse(null);
if (follower != null) {
String remoteActorUri = follow.getFollowingActorUri();
notificationService.createFollowAcceptedNotification(
follower.getId(),
remoteActorUri,
activityId
);
log.info("Created follow accepted notification for user {}", follower.getUsername());
}
}
}
}
} catch (Exception e) {
@ -206,11 +233,24 @@ public class InboxProcessor {
}
String inReplyTo = (String) noteObject.get("inReplyTo");
if (inReplyTo == null) {
log.debug("Create/Note is not a reply, ignoring");
return;
}
if (inReplyTo == null) {
// Standalone Note activity - could be a remote workout/activity
processRemoteActivity(username, actor, noteObject);
} else {
// Note with inReplyTo - this is a comment
processComment(username, actor, noteObject, inReplyTo);
}
} catch (Exception e) {
log.error("Error processing Create activity", e);
}
}
/**
* Process a comment (Note with inReplyTo).
*/
private void processComment(String username, String actor, Map<String, Object> noteObject, String inReplyTo) {
try {
// Extract activity ID from inReplyTo URI
UUID activityId = extractActivityIdFromUri(inReplyTo);
if (activityId == null) {
@ -260,7 +300,82 @@ public class InboxProcessor {
notificationService.createActivityCommentedNotification(localActivity, comment, actor);
} catch (Exception e) {
log.error("Error processing Create activity", e);
log.error("Error processing comment", e);
}
}
/**
* Process a remote activity (standalone Note representing a workout/fitness activity).
*/
private void processRemoteActivity(String username, String actor, Map<String, Object> noteObject) {
try {
String activityUri = (String) noteObject.get("id");
if (activityUri == null) {
log.warn("Remote activity has no id");
return;
}
// Check if activity already exists (deduplication)
if (remoteActivityRepository.existsByActivityUri(activityUri)) {
log.debug("Remote activity already exists: {}", activityUri);
return;
}
// Fetch and cache remote actor
RemoteActor remoteActor = federationService.fetchRemoteActor(actor);
// Check if local user follows this remote actor
User localUser = userRepository.findByUsername(username).orElse(null);
if (localUser == null) {
log.warn("Local user not found: {}", username);
return;
}
boolean isFollowing = followRepository.findByFollowerIdAndFollowingActorUri(
localUser.getId(), actor
).map(follow -> follow.getStatus() == Follow.FollowStatus.ACCEPTED).orElse(false);
if (!isFollowing) {
log.debug("Local user {} is not following {}, ignoring activity", username, actor);
return;
}
// Extract workout metadata
Map<String, Object> workoutData = extractWorkoutData(noteObject);
Map<String, String> attachments = extractAttachments(noteObject);
org.operaton.fitpub.model.entity.RemoteActivity.Visibility visibility = determineVisibility(noteObject);
// Parse published timestamp
String publishedStr = (String) noteObject.get("published");
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
// Build RemoteActivity entity
org.operaton.fitpub.model.entity.RemoteActivity remoteActivity = org.operaton.fitpub.model.entity.RemoteActivity.builder()
.activityUri(activityUri)
.remoteActorUri(actor)
.activityType((String) workoutData.get("activityType"))
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
.description(stripHtml((String) noteObject.get("content")))
.publishedAt(publishedAt)
.totalDistance(parseLong(workoutData.get("distance")))
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
.elevationGain(parseInteger(workoutData.get("elevationGain")))
.averagePaceSeconds(parseDurationSeconds((String) workoutData.get("averagePace")))
.averageHeartRate(parseInteger(workoutData.get("averageHeartRate")))
.maxSpeed(parseDouble(workoutData.get("maxSpeed")))
.averageSpeed(parseDouble(workoutData.get("averageSpeed")))
.calories(parseInteger(workoutData.get("calories")))
.mapImageUrl(attachments.get("mapImage"))
.trackGeojsonUrl(attachments.get("trackGeojson"))
.visibility(visibility)
.activityPubObject(serializeToJson(noteObject))
.build();
remoteActivityRepository.save(remoteActivity);
log.info("Stored remote activity from {}: {} ({})", remoteActor.getUsername(), remoteActivity.getTitle(), activityUri);
} catch (Exception e) {
log.error("Error processing remote activity", e);
}
}
@ -360,4 +475,246 @@ public class InboxProcessor {
return text.trim();
}
// ==================== Remote Activity Helper Methods ====================
/**
* Extract workout/fitness data from a Note object.
* Looks for a "workoutData" extension field containing structured fitness metrics.
*/
private Map<String, Object> extractWorkoutData(Map<String, Object> noteObject) {
Map<String, Object> workoutData = new java.util.HashMap<>();
// Check for custom workoutData extension (FitPub-specific)
Object workoutDataObj = noteObject.get("workoutData");
if (workoutDataObj instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) workoutDataObj;
workoutData.putAll(data);
}
// Fallback: Try to extract from summary or content
String summary = (String) noteObject.get("summary");
if (summary != null) {
// Parse summary like "10.2 km • 48:23 • 4:44/km pace"
workoutData.putIfAbsent("activityType", guessActivityType(summary));
}
return workoutData;
}
/**
* Extract attachment URLs (map image, GeoJSON) from a Note object.
*/
private Map<String, String> extractAttachments(Map<String, Object> noteObject) {
Map<String, String> attachments = new java.util.HashMap<>();
Object attachmentObj = noteObject.get("attachment");
if (attachmentObj instanceof java.util.List) {
@SuppressWarnings("unchecked")
java.util.List<Object> attachmentList = (java.util.List<Object>) attachmentObj;
for (Object item : attachmentList) {
if (item instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> attach = (Map<String, Object>) item;
String type = (String) attach.get("type");
String mediaType = (String) attach.get("mediaType");
String url = (String) attach.get("url");
String name = (String) attach.get("name");
if (url != null) {
// Map image
if ("Image".equals(type) && (mediaType != null && mediaType.startsWith("image/"))) {
if (name != null && name.toLowerCase().contains("map")) {
attachments.put("mapImage", url);
}
}
// GeoJSON track
else if ("Document".equals(type) && "application/geo+json".equals(mediaType)) {
attachments.put("trackGeojson", url);
}
}
}
}
}
return attachments;
}
/**
* Determine visibility from ActivityPub "to" and "cc" fields.
*/
private org.operaton.fitpub.model.entity.RemoteActivity.Visibility determineVisibility(Map<String, Object> noteObject) {
Object toObj = noteObject.get("to");
Object ccObj = noteObject.get("cc");
java.util.List<String> toList = objectToStringList(toObj);
java.util.List<String> ccList = objectToStringList(ccObj);
// Check if Public is in "to" or "cc"
boolean isPublic = toList.contains("https://www.w3.org/ns/activitystreams#Public") ||
ccList.contains("https://www.w3.org/ns/activitystreams#Public") ||
toList.contains("as:Public") ||
ccList.contains("as:Public") ||
toList.contains("Public") ||
ccList.contains("Public");
if (isPublic) {
return org.operaton.fitpub.model.entity.RemoteActivity.Visibility.PUBLIC;
}
// If it has followers in to/cc, it's FOLLOWERS visibility
boolean hasFollowers = toList.stream().anyMatch(s -> s.contains("/followers")) ||
ccList.stream().anyMatch(s -> s.contains("/followers"));
if (hasFollowers) {
return org.operaton.fitpub.model.entity.RemoteActivity.Visibility.FOLLOWERS;
}
// Default to PRIVATE
return org.operaton.fitpub.model.entity.RemoteActivity.Visibility.PRIVATE;
}
/**
* Parse ISO 8601 duration string (PT48M23S) to seconds.
*/
private Long parseDurationSeconds(String isoDuration) {
if (isoDuration == null || isoDuration.isBlank()) {
return null;
}
try {
// Simple ISO 8601 duration parser for PT format
// Format: PT<hours>H<minutes>M<seconds>S
if (!isoDuration.startsWith("PT")) {
return null;
}
String duration = isoDuration.substring(2); // Remove "PT"
long totalSeconds = 0;
// Parse hours
if (duration.contains("H")) {
int hIndex = duration.indexOf("H");
totalSeconds += Long.parseLong(duration.substring(0, hIndex)) * 3600;
duration = duration.substring(hIndex + 1);
}
// Parse minutes
if (duration.contains("M")) {
int mIndex = duration.indexOf("M");
totalSeconds += Long.parseLong(duration.substring(0, mIndex)) * 60;
duration = duration.substring(mIndex + 1);
}
// Parse seconds
if (duration.contains("S")) {
int sIndex = duration.indexOf("S");
totalSeconds += Long.parseLong(duration.substring(0, sIndex));
}
return totalSeconds;
} catch (Exception e) {
log.warn("Failed to parse ISO duration: {}", isoDuration, e);
return null;
}
}
/**
* Serialize object to JSON string.
*/
private String serializeToJson(Object object) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(object);
} catch (Exception e) {
log.error("Failed to serialize object to JSON", e);
return null;
}
}
/**
* Convert object to list of strings (handles both single string and list).
*/
private java.util.List<String> objectToStringList(Object obj) {
if (obj == null) {
return java.util.Collections.emptyList();
}
if (obj instanceof String) {
return java.util.Collections.singletonList((String) obj);
}
if (obj instanceof java.util.List) {
@SuppressWarnings("unchecked")
java.util.List<Object> list = (java.util.List<Object>) obj;
return list.stream()
.filter(item -> item instanceof String)
.map(item -> (String) item)
.collect(java.util.stream.Collectors.toList());
}
return java.util.Collections.emptyList();
}
/**
* Guess activity type from text.
*/
private String guessActivityType(String text) {
if (text == null) {
return "UNKNOWN";
}
String lower = text.toLowerCase();
if (lower.contains("run") || lower.contains("jog")) return "RUN";
if (lower.contains("ride") || lower.contains("bike") || lower.contains("cycl")) return "RIDE";
if (lower.contains("hike") || lower.contains("walk")) return "HIKE";
if (lower.contains("swim")) return "SWIM";
return "UNKNOWN";
}
/**
* Parse Long from object.
*/
private Long parseLong(Object obj) {
if (obj == null) return null;
if (obj instanceof Number) return ((Number) obj).longValue();
if (obj instanceof String) {
try {
return Long.parseLong((String) obj);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* Parse Integer from object.
*/
private Integer parseInteger(Object obj) {
if (obj == null) return null;
if (obj instanceof Number) return ((Number) obj).intValue();
if (obj instanceof String) {
try {
return Integer.parseInt((String) obj);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* Parse Double from object.
*/
private Double parseDouble(Object obj) {
if (obj == null) return null;
if (obj instanceof Number) return ((Number) obj).doubleValue();
if (obj instanceof String) {
try {
return Double.parseDouble((String) obj);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
}

View file

@ -155,6 +155,41 @@ public class NotificationService {
log.debug("Created USER_FOLLOWED notification for user {} from {}", followedUser.getUsername(), actorInfo.username);
}
/**
* Create a notification when a remote user accepts your follow request.
*
* @param followerId the ID of the user who initiated the follow
* @param acceptedActorUri the URI of the remote actor who accepted
* @param activityId the ActivityPub activity ID
*/
public void createFollowAcceptedNotification(UUID followerId, String acceptedActorUri, String activityId) {
// Get follower user
User follower = userRepository.findById(followerId).orElse(null);
if (follower == null) {
log.warn("Could not find follower user with ID: {}", followerId);
return;
}
// Get actor information
ActorInfo actorInfo = getActorInfo(acceptedActorUri);
if (actorInfo == null) {
log.warn("Could not find actor info for URI: {}", acceptedActorUri);
return;
}
Notification notification = Notification.builder()
.user(follower)
.type(Notification.NotificationType.FOLLOW_ACCEPTED)
.actorUri(acceptedActorUri)
.actorDisplayName(actorInfo.displayName)
.actorUsername(actorInfo.username)
.actorAvatarUrl(actorInfo.avatarUrl)
.build();
notificationRepository.save(notification);
log.debug("Created FOLLOW_ACCEPTED notification for user {} from {}", follower.getUsername(), actorInfo.username);
}
/**
* Get all notifications for a user.
*

View file

@ -5,21 +5,28 @@ import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.Follow;
import org.operaton.fitpub.model.entity.RemoteActivity;
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.FollowRepository;
import org.operaton.fitpub.repository.RemoteActivityRepository;
import org.operaton.fitpub.repository.RemoteActorRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Service for managing timelines.
@ -33,6 +40,8 @@ public class TimelineService {
private final ActivityRepository activityRepository;
private final FollowRepository followRepository;
private final UserRepository userRepository;
private final RemoteActivityRepository remoteActivityRepository;
private final RemoteActorRepository remoteActorRepository;
private final org.operaton.fitpub.repository.LikeRepository likeRepository;
private final org.operaton.fitpub.repository.CommentRepository commentRepository;
@ -43,7 +52,8 @@ public class TimelineService {
* Get the federated timeline for a user.
* Includes public activities from:
* - The user's own activities
* - Activities from users they follow (local users only for now)
* - Activities from local users they follow
* - Activities from remote users they follow (federated)
*
* @param userId the authenticated user's ID
* @param pageable pagination parameters
@ -56,44 +66,57 @@ public class TimelineService {
User currentUser = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
// Get list of user IDs that the current user follows
// 1. Get followed remote actor URIs
List<String> remoteActorUris = getFollowedRemoteActorUris(userId);
// 2. Get followed local user IDs
List<UUID> followedUserIds = getFollowedLocalUserIds(userId);
followedUserIds.add(userId); // Include the current user's own activities
// Include the current user's own activities
followedUserIds.add(userId);
// Fetch public and followers-only activities from followed users
Page<Activity> activities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
// 3. Fetch local activities from followed users (fetch more to account for merging)
// We fetch double the page size to have enough items after merging
Pageable expandedPageable = PageRequest.of(0, pageable.getPageSize() * 2);
Page<Activity> localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
followedUserIds,
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
pageable
expandedPageable
);
// Convert to DTOs
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
.map(activity -> {
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
if (activityUser == null) {
return null;
}
TimelineActivityDTO dto = TimelineActivityDTO.fromActivity(
activity,
activityUser.getUsername(),
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
activityUser.getAvatarUrl()
);
// 4. Fetch remote activities from followed remote actors (if any)
List<RemoteActivity> remoteActivities = new ArrayList<>();
if (!remoteActorUris.isEmpty()) {
Page<RemoteActivity> remoteActivitiesPage = remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
remoteActorUris,
List.of(RemoteActivity.Visibility.PUBLIC, RemoteActivity.Visibility.FOLLOWERS),
expandedPageable
);
remoteActivities = remoteActivitiesPage.getContent();
}
// Add social interaction counts
dto.setLikesCount(likeRepository.countByActivityId(activity.getId()));
dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId()));
dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), userId));
// 5. Merge local and remote activities
List<TimelineActivityDTO> mergedActivities = mergeActivities(
localActivities.getContent(),
remoteActivities,
userId
);
return dto;
})
.filter(dto -> dto != null)
.collect(Collectors.toList());
// 6. Sort chronologically (most recent first) and paginate
mergedActivities.sort((a, b) -> {
if (a.getStartedAt() == null && b.getStartedAt() == null) return 0;
if (a.getStartedAt() == null) return 1;
if (b.getStartedAt() == null) return -1;
return b.getStartedAt().compareTo(a.getStartedAt());
});
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
// Apply pagination to the merged list
int start = (int) pageable.getOffset();
int end = Math.min(start + pageable.getPageSize(), mergedActivities.size());
List<TimelineActivityDTO> paginatedActivities = mergedActivities.subList(
Math.min(start, mergedActivities.size()),
end
);
return new PageImpl<>(paginatedActivities, pageable, mergedActivities.size());
}
/**
@ -205,4 +228,84 @@ public class TimelineService {
return followedUserIds;
}
/**
* Get actor URIs of remote users that the given user follows.
*
* @param userId the user's ID
* @return list of followed remote actor URIs
*/
private List<String> getFollowedRemoteActorUris(UUID userId) {
List<Follow> follows = followRepository.findAcceptedFollowingByUserId(userId);
List<String> remoteActorUris = new ArrayList<>();
for (Follow follow : follows) {
// Check if the followed actor is a remote user (not on this instance)
String actorUri = follow.getFollowingActorUri();
if (!actorUri.startsWith(baseUrl + "/users/")) {
remoteActorUris.add(actorUri);
}
}
return remoteActorUris;
}
/**
* Merge local and remote activities into a single list of timeline DTOs.
*
* @param localActivities list of local Activity entities
* @param remoteActivities list of remote RemoteActivity entities
* @param currentUserId the current user's ID (for like status)
* @return merged list of TimelineActivityDTOs
*/
private List<TimelineActivityDTO> mergeActivities(
List<Activity> localActivities,
List<RemoteActivity> remoteActivities,
UUID currentUserId
) {
List<TimelineActivityDTO> merged = new ArrayList<>();
// Convert local activities to DTOs
for (Activity activity : localActivities) {
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
if (activityUser == null) {
continue;
}
TimelineActivityDTO dto = TimelineActivityDTO.fromActivity(
activity,
activityUser.getUsername(),
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
activityUser.getAvatarUrl()
);
// Add social interaction counts
dto.setLikesCount(likeRepository.countByActivityId(activity.getId()));
dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId()));
dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), currentUserId));
merged.add(dto);
}
// Convert remote activities to DTOs
for (RemoteActivity remoteActivity : remoteActivities) {
RemoteActor actor = remoteActorRepository.findByActorUri(remoteActivity.getRemoteActorUri()).orElse(null);
if (actor == null) {
log.warn("Remote actor not found for URI: {}", remoteActivity.getRemoteActorUri());
continue;
}
TimelineActivityDTO dto = TimelineActivityDTO.fromRemoteActivity(remoteActivity, actor);
// Remote activities don't have like/comment counts in this implementation
// (would require additional federation support)
dto.setLikesCount(0L);
dto.setCommentsCount(0L);
dto.setLikedByCurrentUser(false);
merged.add(dto);
}
return merged;
}
}

View file

@ -0,0 +1,249 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* WebFinger client for discovering ActivityPub actors on remote instances.
* Implements RFC 7033 WebFinger protocol with SSRF protection.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class WebFingerClient {
private final ObjectMapper objectMapper;
@Value("${fitpub.domain}")
private String localDomain;
private static final int TIMEOUT_SECONDS = 5;
private static final String WEBFINGER_PATH = "/.well-known/webfinger";
private static final String ACTIVITYPUB_CONTENT_TYPE = "application/activity+json";
/**
* Discovers an ActivityPub actor URI from a handle.
*
* @param handle the handle in format @username@domain or username@domain
* @return the actor URI (e.g., https://domain.com/users/username)
* @throws IllegalArgumentException if handle is invalid or domain is not allowed
* @throws IOException if WebFinger request fails
*/
public String discoverActor(String handle) throws IOException {
log.debug("Discovering actor for handle: {}", handle);
// Parse and validate handle
ParsedHandle parsed = parseHandle(handle);
String username = parsed.username;
String domain = parsed.domain;
// SSRF protection: validate domain
validateDomain(domain);
// Fetch WebFinger resource
Map<String, Object> webFingerResponse = fetchWebFingerResource(domain, username);
// Extract actor URI from links
String actorUri = extractActorUri(webFingerResponse);
if (actorUri == null) {
throw new IOException("No ActivityPub actor link found in WebFinger response");
}
log.info("Discovered actor URI: {} for handle: {}", actorUri, handle);
return actorUri;
}
/**
* Parses a handle into username and domain components.
*
* @param handle the handle (e.g., @username@domain or username@domain)
* @return parsed handle components
* @throws IllegalArgumentException if handle format is invalid
*/
private ParsedHandle parseHandle(String handle) {
if (handle == null || handle.isBlank()) {
throw new IllegalArgumentException("Handle cannot be null or empty");
}
// Remove leading @ if present
String normalized = handle.startsWith("@") ? handle.substring(1) : handle;
// Split on @
String[] parts = normalized.split("@");
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid handle format. Expected: @username@domain or username@domain");
}
String username = parts[0].trim();
String domain = parts[1].trim();
if (username.isEmpty() || domain.isEmpty()) {
throw new IllegalArgumentException("Username and domain cannot be empty");
}
// Validate username format (alphanumeric, underscore, hyphen)
if (!username.matches("^[a-zA-Z0-9_-]+$")) {
throw new IllegalArgumentException("Invalid username format. Allowed characters: a-z, A-Z, 0-9, _, -");
}
// Validate domain format (basic check - allow domains and IP addresses)
// Domain: must have at least one dot and end with 2+ letters
// IP: must be 4 numbers separated by dots
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
if (!isValidDomain && !isValidIP) {
throw new IllegalArgumentException("Invalid domain format");
}
return new ParsedHandle(username, domain);
}
/**
* Fetches WebFinger resource from a remote domain.
*
* @param domain the domain to query
* @param username the username to look up
* @return WebFinger response as a map
* @throws IOException if request fails
*/
private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException {
// Construct WebFinger URL
String resource = "acct:" + username + "@" + domain;
String webFingerUrl = "https://" + domain + WEBFINGER_PATH + "?resource=" + resource;
log.debug("Fetching WebFinger resource: {}", webFingerUrl);
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webFingerUrl))
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.header("Accept", "application/jrd+json, application/json")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("WebFinger request failed with status: " + response.statusCode());
}
// Parse JSON response
@SuppressWarnings("unchecked")
Map<String, Object> webFingerData = objectMapper.readValue(response.body(), Map.class);
return webFingerData;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("WebFinger request interrupted", e);
} catch (Exception e) {
throw new IOException("Failed to fetch WebFinger resource: " + e.getMessage(), e);
}
}
/**
* Validates that a domain is not a private or loopback address (SSRF protection).
*
* @param domain the domain to validate
* @throws IllegalArgumentException if domain resolves to a private IP
*/
private void validateDomain(String domain) {
// Don't allow requests to local domain (should use local API instead)
if (domain.equalsIgnoreCase(localDomain)) {
throw new IllegalArgumentException("Cannot discover local users via WebFinger. Use local API instead.");
}
try {
InetAddress address = InetAddress.getByName(domain);
// Block loopback addresses (127.0.0.0/8, ::1)
if (address.isLoopbackAddress()) {
throw new IllegalArgumentException("Loopback addresses are not allowed: " + domain);
}
// Block site-local addresses (private IPs: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
if (address.isSiteLocalAddress()) {
throw new IllegalArgumentException("Private IP addresses are not allowed: " + domain);
}
// Block link-local addresses (169.254.0.0/16, fe80::/10)
if (address.isLinkLocalAddress()) {
throw new IllegalArgumentException("Link-local addresses are not allowed: " + domain);
}
// Block multicast addresses
if (address.isMulticastAddress()) {
throw new IllegalArgumentException("Multicast addresses are not allowed: " + domain);
}
log.debug("Domain validation passed for: {} (resolved to {})", domain, address.getHostAddress());
} catch (UnknownHostException e) {
throw new IllegalArgumentException("Unable to resolve domain: " + domain, e);
}
}
/**
* Extracts the ActivityPub actor URI from a WebFinger response.
*
* @param webFingerResponse the WebFinger response
* @return the actor URI, or null if not found
*/
private String extractActorUri(Map<String, Object> webFingerResponse) {
Object linksObj = webFingerResponse.get("links");
if (!(linksObj instanceof List)) {
return null;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> links = (List<Map<String, Object>>) linksObj;
// Look for link with rel="self" and type="application/activity+json"
for (Map<String, Object> link : links) {
String rel = (String) link.get("rel");
String type = (String) link.get("type");
String href = (String) link.get("href");
if ("self".equals(rel) &&
(ACTIVITYPUB_CONTENT_TYPE.equals(type) ||
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"".equals(type))) {
return href;
}
}
return null;
}
/**
* Internal class to hold parsed handle components.
*/
private static class ParsedHandle {
final String username;
final String domain;
ParsedHandle(String username, String domain) {
this.username = username;
this.domain = domain;
}
}
}

View file

@ -0,0 +1,63 @@
-- Create remote_activities table for storing metadata from federated fitness activities
-- IMPORTANT: This table stores METADATA ONLY - no full track data
-- Maps and tracks are referenced via URLs pointing to the origin server
CREATE TABLE IF NOT EXISTS remote_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ActivityPub identifiers
activity_uri VARCHAR(512) NOT NULL UNIQUE,
remote_actor_uri VARCHAR(512) NOT NULL,
-- Activity metadata
activity_type VARCHAR(50),
title VARCHAR(500) NOT NULL,
description TEXT,
published_at TIMESTAMP NOT NULL,
-- Fitness metrics
total_distance BIGINT, -- meters
total_duration_seconds BIGINT, -- seconds
elevation_gain INTEGER, -- meters
average_pace_seconds BIGINT, -- seconds per km
average_heart_rate INTEGER, -- BPM
max_speed DOUBLE PRECISION, -- km/h
average_speed DOUBLE PRECISION, -- km/h
calories INTEGER,
-- Remote URLs (point to origin server)
map_image_url VARCHAR(512), -- URL to static map image
track_geojson_url VARCHAR(512), -- URL to GeoJSON track data (optional)
-- Visibility
visibility VARCHAR(20) NOT NULL, -- PUBLIC, FOLLOWERS, PRIVATE
-- Full ActivityPub object as JSONB
activitypub_object JSONB,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Foreign key constraint
CONSTRAINT fk_remote_actor FOREIGN KEY (remote_actor_uri)
REFERENCES remote_actors(actor_uri) ON DELETE CASCADE
);
-- Indexes for performance
CREATE UNIQUE INDEX idx_remote_activity_uri ON remote_activities(activity_uri);
CREATE INDEX idx_remote_activity_actor ON remote_activities(remote_actor_uri);
CREATE INDEX idx_remote_activity_published ON remote_activities(published_at DESC);
CREATE INDEX idx_remote_activity_visibility ON remote_activities(visibility);
CREATE INDEX idx_remote_activity_type ON remote_activities(activity_type);
-- Index for JSONB queries (if needed in the future)
CREATE INDEX idx_remote_activity_jsonb ON remote_activities USING gin(activitypub_object);
-- Comment on table
COMMENT ON TABLE remote_activities IS 'Stores metadata-only for remote fitness activities from federated instances';
COMMENT ON COLUMN remote_activities.activity_uri IS 'Globally unique ActivityPub activity URI';
COMMENT ON COLUMN remote_activities.remote_actor_uri IS 'ActivityPub actor URI of the activity creator';
COMMENT ON COLUMN remote_activities.map_image_url IS 'URL to map image on origin server (no local storage)';
COMMENT ON COLUMN remote_activities.track_geojson_url IS 'URL to GeoJSON on origin server (optional)';
COMMENT ON COLUMN remote_activities.activitypub_object IS 'Full ActivityPub object as JSONB for future re-parsing';

View file

@ -123,7 +123,7 @@ const FitPubTimeline = {
<a href="/users/${activity.username}" class="text-decoration-none text-muted">
@${this.escapeHtml(activity.username)}
</a>
${!activity.isLocal ? ' <i class="bi bi-globe2" title="Federated user"></i>' : ''}
${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''}
${this.formatTimeAgo(activity.startedAt)}
</div>
</div>
@ -136,9 +136,15 @@ const FitPubTimeline = {
<!-- Activity Title and Description -->
<h5 class="card-title">
<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
${this.escapeHtml(activity.title || 'Untitled Activity')}
</a>
${activity.isLocal
? `<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
${this.escapeHtml(activity.title || 'Untitled Activity')}
</a>`
: `<a href="${activity.activityUri || '#'}" target="_blank" class="text-decoration-none text-dark">
${this.escapeHtml(activity.title || 'Untitled Activity')}
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
</a>`
}
</h5>
${activity.description
@ -171,9 +177,14 @@ const FitPubTimeline = {
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
<span class="like-count">${activity.likesCount || 0}</span>
</button>
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
${activity.isLocal
? `<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>`
: `<a href="${activity.activityUri || '#'}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i> View on Origin Server
</a>`
}
<span class="ms-auto text-muted small d-flex align-items-center gap-2">
${activity.commentsCount > 0 ? `<span><i class="bi bi-chat-left-text"></i> ${activity.commentsCount}</span>` : ''}
<span>
@ -284,6 +295,30 @@ const FitPubTimeline = {
return;
}
// Handle remote activities differently - show static map image
if (!activity.isLocal) {
if (activity.mapImageUrl) {
mapElement.innerHTML = `
<div class="position-relative w-100 h-100">
<img src="${this.escapeHtml(activity.mapImageUrl)}"
alt="Activity Map"
class="img-fluid w-100 h-100"
style="object-fit: cover; border-radius: 8px;"
onerror="this.parentElement.innerHTML='<div class=\\'d-flex align-items-center justify-content-center h-100 bg-light\\'><p class=\\'text-muted\\'>Map not available</p></div>'">
<div class="position-absolute top-0 end-0 m-2">
<span class="badge bg-secondary">
<i class="bi bi-globe2"></i> Remote Map
</span>
</div>
</div>
`;
} else {
mapElement.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 bg-light"><p class="text-muted">No map available for this remote activity</p></div>';
}
return;
}
// Handle local activities - render interactive Leaflet map
try {
// Fetch track data
const response = await fetch(`/api/activities/${activity.id}/track`);

View file

@ -17,7 +17,69 @@
<h2 class="mb-1">
<i class="bi bi-people"></i> Discover Users
</h2>
<p class="text-muted">Find and connect with athletes on FitPub</p>
<p class="text-muted">Find and connect with athletes on FitPub and across the Fediverse</p>
</div>
</div>
<!-- Remote User Discovery -->
<div class="row mb-4">
<div class="col-12 col-md-8 col-lg-6">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">
<i class="bi bi-globe"></i> Follow Remote Users
</h5>
<p class="text-muted small mb-3">
Connect with users from other FitPub instances or ActivityPub-compatible platforms like Mastodon
</p>
<form id="remoteUserSearchForm">
<div class="input-group">
<span class="input-group-text">@</span>
<input type="text"
class="form-control"
id="remoteUserHandle"
placeholder="username@domain.com"
pattern="[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+"
autocomplete="off"
required>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Search
</button>
</div>
<div class="form-text">
Enter a handle like: alice@fitpub.example or bob@mastodon.social
</div>
</form>
<!-- Remote User Result -->
<div id="remoteUserResult" class="mt-3 d-none">
<!-- Will be populated by JavaScript -->
</div>
<!-- Remote User Error -->
<div id="remoteUserError" class="alert alert-danger mt-3 d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="remoteUserErrorText"></span>
</div>
<!-- Remote User Loading -->
<div id="remoteUserLoading" class="text-center mt-3 d-none">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Searching...</span>
</div>
<span class="ms-2 text-muted">Discovering remote user...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Local User Search -->
<div class="row mb-3">
<div class="col-12">
<h5 class="mb-3">
<i class="bi bi-house-door"></i> Local Users
</h5>
</div>
</div>
@ -104,6 +166,13 @@
const pageSize = 12;
document.addEventListener('DOMContentLoaded', function() {
// Remote user search form handler
const remoteUserSearchForm = document.getElementById('remoteUserSearchForm');
remoteUserSearchForm.addEventListener('submit', async function(e) {
e.preventDefault();
await searchRemoteUser();
});
// Load initial users (browse mode)
loadUsers();
@ -158,6 +227,140 @@
document.getElementById('searchInfo').classList.add('d-none');
}
async function searchRemoteUser() {
const handle = document.getElementById('remoteUserHandle').value.trim();
if (!handle) {
return;
}
// Show loading, hide result and error
document.getElementById('remoteUserLoading').classList.remove('d-none');
document.getElementById('remoteUserResult').classList.add('d-none');
document.getElementById('remoteUserError').classList.add('d-none');
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/users/discover-remote?handle=${encodeURIComponent(handle)}`
);
if (!response.ok) {
if (response.status === 400) {
throw new Error('Invalid handle format. Please use format: username@domain.com');
} else if (response.status === 404) {
throw new Error('User not found. Please check the handle and try again.');
} else {
throw new Error('Failed to discover remote user. Please try again.');
}
}
const actor = await response.json();
// Hide loading
document.getElementById('remoteUserLoading').classList.add('d-none');
// Display remote user
displayRemoteUser(actor);
} catch (error) {
console.error('Error discovering remote user:', error);
// Hide loading
document.getElementById('remoteUserLoading').classList.add('d-none');
// Show error
document.getElementById('remoteUserErrorText').textContent = error.message;
document.getElementById('remoteUserError').classList.remove('d-none');
}
}
function displayRemoteUser(actor) {
const resultDiv = document.getElementById('remoteUserResult');
const avatarHtml = actor.avatarUrl
? `<img src="${escapeHtml(actor.avatarUrl)}"
alt="${escapeHtml(actor.username)}"
class="rounded-circle"
width="60"
height="60"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">`
: '';
const avatarPlaceholder = `
<div class="avatar-placeholder ${actor.avatarUrl ? 'd-none' : ''}"
style="width: 60px; height: 60px;">
<i class="bi bi-person-circle"></i>
</div>
`;
resultDiv.innerHTML = `
<div class="card border-primary">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<div class="me-3">
${avatarHtml}
${avatarPlaceholder}
</div>
<div class="flex-grow-1">
<h6 class="mb-1">${escapeHtml(actor.displayName || actor.username)}</h6>
<p class="text-muted small mb-0">
@${escapeHtml(actor.handle)}
<span class="badge bg-info ms-2">Remote</span>
</p>
</div>
</div>
${actor.bio
? `<p class="card-text small mb-3">${escapeHtml(actor.bio)}</p>`
: '<p class="card-text small text-muted mb-3 fst-italic">No bio</p>'
}
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="followRemoteUser('${escapeHtml(actor.handle)}')">
<i class="bi bi-person-plus"></i> Follow
</button>
${actor.actorUri
? `<a href="${escapeHtml(actor.actorUri)}"
target="_blank"
class="btn btn-outline-secondary">
<i class="bi bi-box-arrow-up-right"></i> View Profile
</a>`
: ''
}
</div>
</div>
</div>
`;
resultDiv.classList.remove('d-none');
}
async function followRemoteUser(handle) {
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/users/${encodeURIComponent(handle)}/follow`,
{ method: 'POST' }
);
if (!response.ok) {
throw new Error('Failed to follow user');
}
const result = await response.json();
// Show success message
FitPub.showAlert('success', result.message || `Follow request sent to ${handle}`);
// Clear the search form
document.getElementById('remoteUserHandle').value = '';
document.getElementById('remoteUserResult').classList.add('d-none');
} catch (error) {
console.error('Error following remote user:', error);
FitPub.showAlert('error', 'Failed to follow user. Please try again.');
}
}
async function loadUsers() {
try {
// Show loading