Better Federation Support
This commit is contained in:
parent
15b420b87a
commit
5b687883b0
22 changed files with 2931 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
249
src/main/java/org/operaton/fitpub/service/WebFingerClient.java
Normal file
249
src/main/java/org/operaton/fitpub/service/WebFingerClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue