From 5b687883b00a2d2d0252bc460a87c6e35f303612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Mon, 15 Dec 2025 21:55:17 +0100 Subject: [PATCH] Better Federation Support --- FEDERATION_TESTING_GUIDE.md | 516 ++++++++++++++++++ .../fitpub/config/SecurityConfig.java | 1 + .../fitpub/controller/UserController.java | 110 +++- .../model/activitypub/WebFingerLink.java | 55 ++ .../model/activitypub/WebFingerResource.java | 60 ++ .../fitpub/model/dto/TimelineActivityDTO.java | 46 ++ .../fitpub/model/entity/RemoteActivity.java | 188 +++++++ .../fitpub/repository/FollowRepository.java | 10 + .../repository/RemoteActivityRepository.java | 111 ++++ .../fitpub/service/FederationService.java | 47 ++ .../fitpub/service/InboxProcessor.java | 369 ++++++++++++- .../fitpub/service/NotificationService.java | 35 ++ .../fitpub/service/TimelineService.java | 163 +++++- .../fitpub/service/WebFingerClient.java | 249 +++++++++ .../V13__create_remote_activities_table.sql | 63 +++ src/main/resources/static/js/timeline.js | 49 +- .../resources/templates/users/discover.html | 205 ++++++- .../FederationFollowFlowIntegrationTest.java | 418 ++++++++++++++ .../service/TrainingLoadServiceTest.java | 14 +- .../fitpub/service/WebFingerClientTest.java | 217 ++++++++ src/test/resources/application-test.yml | 52 ++ src/test/resources/init-test-db.sql | 2 + 22 files changed, 2931 insertions(+), 49 deletions(-) create mode 100644 FEDERATION_TESTING_GUIDE.md create mode 100644 src/main/java/org/operaton/fitpub/model/activitypub/WebFingerLink.java create mode 100644 src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResource.java create mode 100644 src/main/java/org/operaton/fitpub/model/entity/RemoteActivity.java create mode 100644 src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java create mode 100644 src/main/java/org/operaton/fitpub/service/WebFingerClient.java create mode 100644 src/main/resources/db/migration/V13__create_remote_activities_table.sql create mode 100644 src/test/java/org/operaton/fitpub/integration/FederationFollowFlowIntegrationTest.java create mode 100644 src/test/java/org/operaton/fitpub/service/WebFingerClientTest.java create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/init-test-db.sql diff --git a/FEDERATION_TESTING_GUIDE.md b/FEDERATION_TESTING_GUIDE.md new file mode 100644 index 0000000..73edbe9 --- /dev/null +++ b/FEDERATION_TESTING_GUIDE.md @@ -0,0 +1,516 @@ +# FitPub Federation Testing Guide + +This guide explains how to test the instance-to-instance federation functionality by running two FitPub instances locally. + +## Prerequisites + +- Java 17+ +- Maven 3.8+ +- PostgreSQL 13+ with PostGIS extension +- Two separate PostgreSQL databases +- Two different port numbers for the applications + +## Setup + +### Step 1: Create Two PostgreSQL Databases + +```bash +# Connect to PostgreSQL +psql -U postgres + +# Create databases +CREATE DATABASE fitpub_instance1; +CREATE DATABASE fitpub_instance2; + +# Enable PostGIS extension for both databases +\c fitpub_instance1 +CREATE EXTENSION IF NOT EXISTS postgis; + +\c fitpub_instance2 +CREATE EXTENSION IF NOT EXISTS postgis; + +\q +``` + +### Step 2: Prepare Application Profiles + +Create two separate application configuration files: + +#### `application-instance1.yml` + +```yaml +server: + port: 8080 + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/fitpub_instance1 + username: postgres + password: your_password + jpa: + hibernate: + ddl-auto: validate + +fitpub: + base-url: http://localhost:8080 + domain: localhost:8080 + +logging: + level: + org.operaton.fitpub: DEBUG +``` + +#### `application-instance2.yml` + +```yaml +server: + port: 8081 + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/fitpub_instance2 + username: postgres + password: your_password + jpa: + hibernate: + ddl-auto: validate + +fitpub: + base-url: http://localhost:8081 + domain: localhost:8081 + +logging: + level: + org.operaton.fitpub: DEBUG +``` + +### Step 3: Build the Application + +```bash +mvn clean package -DskipTests +``` + +## Running the Instances + +### Terminal 1: Start Instance 1 + +```bash +java -jar target/feditrack-1.0-SNAPSHOT.jar --spring.profiles.active=instance1 +``` + +Wait for the application to start completely. You should see: +``` +Started FitPubApplication in X.XXX seconds +``` + +### Terminal 2: Start Instance 2 + +```bash +java -jar target/feditrack-1.0-SNAPSHOT.jar --spring.profiles.active=instance2 +``` + +## Test Scenarios + +### Test 1: User Registration + +**Instance 1 (http://localhost:8080)** +1. Navigate to http://localhost:8080/register +2. Register user: `alice` / `alice@localhost1.test` / `password123` +3. Login + +**Instance 2 (http://localhost:8081)** +1. Navigate to http://localhost:8081/register +2. Register user: `bob` / `bob@localhost2.test` / `password123` +3. Login + +### Test 2: WebFinger Discovery + +**From Instance 1, discover Bob on Instance 2:** + +```bash +curl http://localhost:8080/.well-known/webfinger?resource=acct:bob@localhost:8081 +``` + +Expected response: +```json +{ + "subject": "acct:bob@localhost:8081", + "aliases": [ + "http://localhost:8081/users/bob" + ], + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "http://localhost:8081/users/bob" + } + ] +} +``` + +**From Instance 2, discover Alice on Instance 1:** + +```bash +curl http://localhost:8081/.well-known/webfinger?resource=acct:alice@localhost:8080 +``` + +### Test 3: Remote User Discovery via UI + +**On Instance 1 (Alice following Bob):** + +1. Login as Alice +2. Navigate to http://localhost:8080/discover +3. In the "Follow Remote Users" section, enter: `bob@localhost:8081` +4. Click "Search" +5. Verify Bob's profile appears with avatar, display name, and bio +6. Click "Follow" button +7. Verify notification appears: "Follow request sent to bob@localhost:8081" + +**Verify on Instance 2 (Bob's perspective):** + +1. Login as Bob on http://localhost:8081 +2. Check notifications - you should see: "alice@localhost:8080 followed you" +3. Navigate to http://localhost:8081/users/bob/followers +4. Verify alice@localhost:8080 appears in followers list + +### Test 4: Following Relationship Check + +**Check via API:** + +```bash +# From Instance 2, check Bob's followers +curl http://localhost:8081/api/users/bob/followers | jq + +# Expected: Alice should be in the list +``` + +**Check via UI:** + +On Instance 2: +1. Navigate to http://localhost:8081/users/bob +2. Check "Followers" count - should be 1 +3. Click on "Followers" - Alice should be listed + +On Instance 1: +1. Navigate to http://localhost:8080/users/alice +2. Check "Following" count - should be 1 +3. Click on "Following" - Bob should be listed + +### Test 5: Activity Federation + +**Bob uploads a workout on Instance 2:** + +1. Login as Bob on http://localhost:8081 +2. Navigate to http://localhost:8081/upload +3. Upload a FIT file (use test file from `src/test/resources/`) +4. Set title: "Morning 10K Run" +5. Set visibility: "Public" +6. Click "Upload" + +**Verify on Instance 1 (Alice's federated timeline):** + +1. Login as Alice on http://localhost:8080 +2. Navigate to http://localhost:8080/timeline/federated +3. Verify Bob's "Morning 10K Run" activity appears with: + - Federation badge: "🌐 Remote" + - Bob's avatar and @bob@localhost:8081 + - Map preview (if map image URL is available) + - Metrics (distance, duration, pace, elevation) + - Link to view on origin server + +### Test 6: Remote Activity Details + +**Click on Remote Activity:** + +From Alice's federated timeline: +1. Click on Bob's "Morning 10K Run" activity title +2. Verify it opens Bob's activity on Instance 2 (http://localhost:8081/activities/{id}) in a new tab +3. Alternatively, click "View on Origin Server" button + +### Test 7: Incoming Activity via ActivityPub + +**Test with manual ActivityPub POST:** + +```bash +# Create a test activity +cat > test-activity.json < 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> 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> 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 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. * diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerLink.java b/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerLink.java new file mode 100644 index 0000000..1be155c --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerLink.java @@ -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 titles; + + /** + * Optional properties for the link. + */ + private java.util.Map properties; +} diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResource.java b/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResource.java new file mode 100644 index 0000000..17468cf --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResource.java @@ -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 aliases; + + /** + * Links to related resources. + */ + private List links; + + /** + * Optional properties (key-value pairs). + */ + private Map properties; +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java index e8c282c..923639f 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java @@ -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. */ diff --git a/src/main/java/org/operaton/fitpub/model/entity/RemoteActivity.java b/src/main/java/org/operaton/fitpub/model/entity/RemoteActivity.java new file mode 100644 index 0000000..e91dc96 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/RemoteActivity.java @@ -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 + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/FollowRepository.java b/src/main/java/org/operaton/fitpub/repository/FollowRepository.java index 774dacc..31c00d3 100644 --- a/src/main/java/org/operaton/fitpub/repository/FollowRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/FollowRepository.java @@ -67,4 +67,14 @@ public interface FollowRepository extends JpaRepository { * @return the follow relationship if it exists */ Optional 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 findByRemoteActorUriAndFollowingActorUri(String remoteActorUri, String followingActorUri); } diff --git a/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java b/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java new file mode 100644 index 0000000..b9656a7 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/RemoteActivityRepository.java @@ -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 { + + /** + * 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 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 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 findByRemoteActorUriInAndVisibilityIn( + @Param("actorUris") List actorUris, + @Param("visibilities") List 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 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 findByRemoteActorUriInAndActivityType( + @Param("actorUris") List 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); +} diff --git a/src/main/java/org/operaton/fitpub/service/FederationService.java b/src/main/java/org/operaton/fitpub/service/FederationService.java index 3f997f4..9735608 100644 --- a/src/main/java/org/operaton/fitpub/service/FederationService.java +++ b/src/main/java/org/operaton/fitpub/service/FederationService.java @@ -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 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. * diff --git a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java index 5deccf0..3dae22d 100644 --- a/src/main/java/org/operaton/fitpub/service/InboxProcessor.java +++ b/src/main/java/org/operaton/fitpub/service/InboxProcessor.java @@ -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 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 acceptObject = (Map) 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 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 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 workoutData = extractWorkoutData(noteObject); + Map 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 extractWorkoutData(Map noteObject) { + Map workoutData = new java.util.HashMap<>(); + + // Check for custom workoutData extension (FitPub-specific) + Object workoutDataObj = noteObject.get("workoutData"); + if (workoutDataObj instanceof Map) { + @SuppressWarnings("unchecked") + Map data = (Map) 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 extractAttachments(Map noteObject) { + Map attachments = new java.util.HashMap<>(); + + Object attachmentObj = noteObject.get("attachment"); + if (attachmentObj instanceof java.util.List) { + @SuppressWarnings("unchecked") + java.util.List attachmentList = (java.util.List) attachmentObj; + + for (Object item : attachmentList) { + if (item instanceof Map) { + @SuppressWarnings("unchecked") + Map attach = (Map) 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 noteObject) { + Object toObj = noteObject.get("to"); + Object ccObj = noteObject.get("cc"); + + java.util.List toList = objectToStringList(toObj); + java.util.List 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: PTHMS + 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 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 list = (java.util.List) 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; + } } diff --git a/src/main/java/org/operaton/fitpub/service/NotificationService.java b/src/main/java/org/operaton/fitpub/service/NotificationService.java index 76fc516..aa77be2 100644 --- a/src/main/java/org/operaton/fitpub/service/NotificationService.java +++ b/src/main/java/org/operaton/fitpub/service/NotificationService.java @@ -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. * diff --git a/src/main/java/org/operaton/fitpub/service/TimelineService.java b/src/main/java/org/operaton/fitpub/service/TimelineService.java index 2575dac..3d94728 100644 --- a/src/main/java/org/operaton/fitpub/service/TimelineService.java +++ b/src/main/java/org/operaton/fitpub/service/TimelineService.java @@ -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 remoteActorUris = getFollowedRemoteActorUris(userId); + + // 2. Get followed local user IDs List 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 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 localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc( followedUserIds, List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS), - pageable + expandedPageable ); - // Convert to DTOs - List 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 remoteActivities = new ArrayList<>(); + if (!remoteActorUris.isEmpty()) { + Page 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 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 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 getFollowedRemoteActorUris(UUID userId) { + List follows = followRepository.findAcceptedFollowingByUserId(userId); + List 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 mergeActivities( + List localActivities, + List remoteActivities, + UUID currentUserId + ) { + List 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; + } } diff --git a/src/main/java/org/operaton/fitpub/service/WebFingerClient.java b/src/main/java/org/operaton/fitpub/service/WebFingerClient.java new file mode 100644 index 0000000..1ab2a24 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/WebFingerClient.java @@ -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 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 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 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 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 webFingerResponse) { + Object linksObj = webFingerResponse.get("links"); + if (!(linksObj instanceof List)) { + return null; + } + + @SuppressWarnings("unchecked") + List> links = (List>) linksObj; + + // Look for link with rel="self" and type="application/activity+json" + for (Map 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; + } + } +} diff --git a/src/main/resources/db/migration/V13__create_remote_activities_table.sql b/src/main/resources/db/migration/V13__create_remote_activities_table.sql new file mode 100644 index 0000000..b4efe02 --- /dev/null +++ b/src/main/resources/db/migration/V13__create_remote_activities_table.sql @@ -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'; diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index 96affb6..08e00b2 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -123,7 +123,7 @@ const FitPubTimeline = { @${this.escapeHtml(activity.username)} - ${!activity.isLocal ? ' ' : ''} + ${!activity.isLocal ? ' Remote' : ''} • ${this.formatTimeAgo(activity.startedAt)} @@ -136,9 +136,15 @@ const FitPubTimeline = {
- - ${this.escapeHtml(activity.title || 'Untitled Activity')} - + ${activity.isLocal + ? ` + ${this.escapeHtml(activity.title || 'Untitled Activity')} + ` + : ` + ${this.escapeHtml(activity.title || 'Untitled Activity')} + + ` + }
${activity.description @@ -171,9 +177,14 @@ const FitPubTimeline = { - - View Details - + ${activity.isLocal + ? ` + View Details + ` + : ` + View on Origin Server + ` + } ${activity.commentsCount > 0 ? ` ${activity.commentsCount}` : ''} @@ -284,6 +295,30 @@ const FitPubTimeline = { return; } + // Handle remote activities differently - show static map image + if (!activity.isLocal) { + if (activity.mapImageUrl) { + mapElement.innerHTML = ` +
+ Activity Map +
+ + Remote Map + +
+
+ `; + } else { + mapElement.innerHTML = '

No map available for this remote activity

'; + } + return; + } + + // Handle local activities - render interactive Leaflet map try { // Fetch track data const response = await fetch(`/api/activities/${activity.id}/track`); diff --git a/src/main/resources/templates/users/discover.html b/src/main/resources/templates/users/discover.html index 25a516c..c79e772 100644 --- a/src/main/resources/templates/users/discover.html +++ b/src/main/resources/templates/users/discover.html @@ -17,7 +17,69 @@

Discover Users

-

Find and connect with athletes on FitPub

+

Find and connect with athletes on FitPub and across the Fediverse

+ + + + +
+
+
+
+
+ Follow Remote Users +
+

+ Connect with users from other FitPub instances or ActivityPub-compatible platforms like Mastodon +

+
+
+ @ + + +
+
+ Enter a handle like: alice@fitpub.example or bob@mastodon.social +
+
+ + +
+ +
+ + + + + +
+
+ Searching... +
+ Discovering remote user... +
+
+
+
+
+ + +
+
+
+ Local Users +
@@ -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 + ? `${escapeHtml(actor.username)}` + : ''; + + const avatarPlaceholder = ` +
+ +
+ `; + + resultDiv.innerHTML = ` +
+
+
+
+ ${avatarHtml} + ${avatarPlaceholder} +
+
+
${escapeHtml(actor.displayName || actor.username)}
+

+ @${escapeHtml(actor.handle)} + Remote +

+
+
+ + ${actor.bio + ? `

${escapeHtml(actor.bio)}

` + : '

No bio

' + } + +
+ + ${actor.actorUri + ? ` + View Profile + ` + : '' + } +
+
+
+ `; + + 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 diff --git a/src/test/java/org/operaton/fitpub/integration/FederationFollowFlowIntegrationTest.java b/src/test/java/org/operaton/fitpub/integration/FederationFollowFlowIntegrationTest.java new file mode 100644 index 0000000..c4bbbb0 --- /dev/null +++ b/src/test/java/org/operaton/fitpub/integration/FederationFollowFlowIntegrationTest.java @@ -0,0 +1,418 @@ +package org.operaton.fitpub.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.operaton.fitpub.model.entity.Follow; +import org.operaton.fitpub.model.entity.RemoteActor; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.RemoteActorRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.security.JwtTokenProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integration tests for the complete federation follow flow. + * Tests the entire workflow from following a remote user to receiving accept notifications. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class FederationFollowFlowIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private FollowRepository followRepository; + + @Autowired + private RemoteActorRepository remoteActorRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Value("${fitpub.base-url}") + private String baseUrl; + + private User testUser; + private String authToken; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + // Generate RSA key pair for ActivityPub + KeyPair keyPair = generateRsaKeyPair(); + String publicKey = encodePublicKey(keyPair.getPublic().getEncoded()); + String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded()); + + // Create test user + testUser = User.builder() + .username("testuser") + .email("test@example.com") + .passwordHash(passwordEncoder.encode("password123")) + .displayName("Test User") + .publicKey(publicKey) + .privateKey(privateKey) + .enabled(true) + .build(); + testUser = userRepository.save(testUser); + + // Generate JWT token + authToken = jwtTokenProvider.createToken(testUser.getUsername()); + } + + private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + return keyGen.generateKeyPair(); + } + + private String encodePublicKey(byte[] keyBytes) { + String base64 = Base64.getEncoder().encodeToString(keyBytes); + return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----"; + } + + private String encodePrivateKey(byte[] keyBytes) { + String base64 = Base64.getEncoder().encodeToString(keyBytes); + return "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----"; + } + + @Test + @Disabled("Requires mocking external HTTP calls to WebFinger and remote ActivityPub servers") + @DisplayName("Should follow a remote user via handle format @username@domain") + void testFollowRemoteUserWithHandle() throws Exception { + String remoteHandle = "@alice@fitpub.example"; + + // Perform follow request + MvcResult result = mockMvc.perform(post("/api/users/" + remoteHandle + "/follow") + .header("Authorization", "Bearer " + authToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PENDING")) + .andReturn(); + + // Verify follow record was created with PENDING status + String actorUri = baseUrl + "/users/alice"; // Would be resolved via WebFinger in real scenario + Follow follow = followRepository.findByFollowerIdAndFollowingActorUri(testUser.getId(), actorUri) + .orElse(null); + + // Note: In a real scenario, this would require mocking WebFinger discovery + // For now, we verify the endpoint accepts the format + assertThat(result.getResponse().getContentAsString()).contains("PENDING"); + } + + @Test + @DisplayName("Should process incoming Follow activity and create follow relationship") + void testProcessIncomingFollowActivity() throws Exception { + // Create a remote actor + RemoteActor remoteActor = RemoteActor.builder() + .actorUri("https://remote.example/users/bob") + .username("bob") + .domain("remote.example") + .displayName("Bob Remote") + .inboxUrl("https://remote.example/users/bob/inbox") + .outboxUrl("https://remote.example/users/bob/outbox") + .publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----") + .lastFetchedAt(Instant.now()) + .build(); + remoteActor = remoteActorRepository.save(remoteActor); + + // Create Follow activity + String followId = "https://remote.example/activities/follow/" + UUID.randomUUID(); + Map followActivity = Map.of( + "@context", "https://www.w3.org/ns/activitystreams", + "type", "Follow", + "id", followId, + "actor", remoteActor.getActorUri(), + "object", baseUrl + "/users/" + testUser.getUsername(), + "published", Instant.now().toString() + ); + + // Post to inbox (without signature validation for test) + mockMvc.perform(post("/users/" + testUser.getUsername() + "/inbox") + .contentType("application/activity+json") + .content(objectMapper.writeValueAsString(followActivity))) + .andExpect(status().isAccepted()); + + // Verify follow relationship was created + Follow follow = followRepository.findByRemoteActorUriAndFollowingActorUri( + remoteActor.getActorUri(), + baseUrl + "/users/" + testUser.getUsername() + ).orElse(null); + + assertThat(follow).isNotNull(); + assertThat(follow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED); + } + + @Test + @DisplayName("Should process Accept activity and update follow status to ACCEPTED") + void testProcessAcceptActivity() throws Exception { + // Create a remote actor + RemoteActor remoteActor = RemoteActor.builder() + .actorUri("https://remote.example/users/carol") + .username("carol") + .domain("remote.example") + .displayName("Carol Remote") + .inboxUrl("https://remote.example/users/carol/inbox") + .outboxUrl("https://remote.example/users/carol/outbox") + .publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----") + .lastFetchedAt(Instant.now()) + .build(); + remoteActor = remoteActorRepository.save(remoteActor); + + // Create pending follow + String followActivityId = baseUrl + "/activities/follow/" + UUID.randomUUID(); + Follow pendingFollow = Follow.builder() + .followerId(testUser.getId()) + .followingActorUri(remoteActor.getActorUri()) + .status(Follow.FollowStatus.PENDING) + .activityId(followActivityId) + .build(); + pendingFollow = followRepository.save(pendingFollow); + + // Create Accept activity + Map acceptActivity = Map.of( + "@context", "https://www.w3.org/ns/activitystreams", + "type", "Accept", + "id", "https://remote.example/activities/accept/" + UUID.randomUUID(), + "actor", remoteActor.getActorUri(), + "object", followActivityId + ); + + // Post Accept to inbox + mockMvc.perform(post("/users/" + testUser.getUsername() + "/inbox") + .contentType("application/activity+json") + .content(objectMapper.writeValueAsString(acceptActivity))) + .andExpect(status().isAccepted()); + + // Verify follow status was updated to ACCEPTED + Follow updatedFollow = followRepository.findById(pendingFollow.getId()).orElseThrow(); + assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED); + } + + @Test + @DisplayName("Should process Undo Follow activity and remove follow relationship") + void testProcessUndoFollowActivity() throws Exception { + // Create a remote actor + RemoteActor remoteActor = RemoteActor.builder() + .actorUri("https://remote.example/users/dave") + .username("dave") + .domain("remote.example") + .displayName("Dave Remote") + .inboxUrl("https://remote.example/users/dave/inbox") + .outboxUrl("https://remote.example/users/dave/outbox") + .publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----") + .lastFetchedAt(Instant.now()) + .build(); + remoteActor = remoteActorRepository.save(remoteActor); + + // Create accepted follow + Follow acceptedFollow = Follow.builder() + .remoteActorUri(remoteActor.getActorUri()) + .followingActorUri(baseUrl + "/users/" + testUser.getUsername()) + .status(Follow.FollowStatus.ACCEPTED) + .build(); + acceptedFollow = followRepository.save(acceptedFollow); + + // Create Undo Follow activity + Map undoActivity = Map.of( + "@context", "https://www.w3.org/ns/activitystreams", + "type", "Undo", + "id", "https://remote.example/activities/undo/" + UUID.randomUUID(), + "actor", remoteActor.getActorUri(), + "object", Map.of( + "type", "Follow", + "actor", remoteActor.getActorUri(), + "object", baseUrl + "/users/" + testUser.getUsername() + ) + ); + + // Post Undo to inbox + mockMvc.perform(post("/users/" + testUser.getUsername() + "/inbox") + .contentType("application/activity+json") + .content(objectMapper.writeValueAsString(undoActivity))) + .andExpect(status().isAccepted()); + + // Verify follow was deleted + boolean followExists = followRepository.existsById(acceptedFollow.getId()); + assertThat(followExists).isFalse(); + } + + @Test + @DisplayName("Should return followers list including both local and remote followers") + void testGetFollowersList() throws Exception { + // Generate keypair for local follower + KeyPair keyPair = generateRsaKeyPair(); + + // Create a local follower + User localFollower = User.builder() + .username("localfollower") + .email("local@example.com") + .passwordHash(passwordEncoder.encode("password")) + .displayName("Local Follower") + .publicKey(encodePublicKey(keyPair.getPublic().getEncoded())) + .privateKey(encodePrivateKey(keyPair.getPrivate().getEncoded())) + .enabled(true) + .build(); + localFollower = userRepository.save(localFollower); + + Follow localFollow = Follow.builder() + .followerId(localFollower.getId()) + .followingActorUri(baseUrl + "/users/" + testUser.getUsername()) + .status(Follow.FollowStatus.ACCEPTED) + .build(); + followRepository.save(localFollow); + + // Create a remote follower + RemoteActor remoteFollower = RemoteActor.builder() + .actorUri("https://remote.example/users/eve") + .username("eve") + .domain("remote.example") + .displayName("Eve Remote") + .inboxUrl("https://remote.example/users/eve/inbox") + .outboxUrl("https://remote.example/users/eve/outbox") + .publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----") + .lastFetchedAt(Instant.now()) + .build(); + remoteFollower = remoteActorRepository.save(remoteFollower); + + Follow remoteFollow = Follow.builder() + .remoteActorUri(remoteFollower.getActorUri()) + .followingActorUri(baseUrl + "/users/" + testUser.getUsername()) + .status(Follow.FollowStatus.ACCEPTED) + .build(); + followRepository.save(remoteFollow); + + // Get followers list + mockMvc.perform(get("/api/users/" + testUser.getUsername() + "/followers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[?(@.username == 'localfollower')]").exists()) + .andExpect(jsonPath("$[?(@.username == 'eve')]").exists()) + .andExpect(jsonPath("$[?(@.local == true)]").exists()) + .andExpect(jsonPath("$[?(@.local == false)]").exists()); + } + + @Test + @DisplayName("Should return following list including both local and remote users") + void testGetFollowingList() throws Exception { + // Generate keypair for local followed user + KeyPair keyPair = generateRsaKeyPair(); + + // Create a local user being followed + User localFollowed = User.builder() + .username("localfollowed") + .email("followed@example.com") + .passwordHash(passwordEncoder.encode("password")) + .displayName("Local Followed") + .publicKey(encodePublicKey(keyPair.getPublic().getEncoded())) + .privateKey(encodePrivateKey(keyPair.getPrivate().getEncoded())) + .enabled(true) + .build(); + localFollowed = userRepository.save(localFollowed); + + Follow localFollow = Follow.builder() + .followerId(testUser.getId()) + .followingActorUri(baseUrl + "/users/" + localFollowed.getUsername()) + .status(Follow.FollowStatus.ACCEPTED) + .build(); + followRepository.save(localFollow); + + // Create a remote user being followed + RemoteActor remoteFollowed = RemoteActor.builder() + .actorUri("https://remote.example/users/frank") + .username("frank") + .domain("remote.example") + .displayName("Frank Remote") + .inboxUrl("https://remote.example/users/frank/inbox") + .outboxUrl("https://remote.example/users/frank/outbox") + .publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----") + .lastFetchedAt(Instant.now()) + .build(); + remoteFollowed = remoteActorRepository.save(remoteFollowed); + + Follow remoteFollow = Follow.builder() + .followerId(testUser.getId()) + .followingActorUri(remoteFollowed.getActorUri()) + .status(Follow.FollowStatus.ACCEPTED) + .build(); + followRepository.save(remoteFollow); + + // Get following list + mockMvc.perform(get("/api/users/" + testUser.getUsername() + "/following")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[?(@.username == 'localfollowed')]").exists()) + .andExpect(jsonPath("$[?(@.username == 'frank')]").exists()) + .andExpect(jsonPath("$[?(@.local == true)]").exists()) + .andExpect(jsonPath("$[?(@.local == false)]").exists()); + } + + @Test + @Disabled("Requires mocking external HTTP calls to WebFinger and remote ActivityPub servers") + @DisplayName("Should prevent duplicate follow relationships") + void testPreventDuplicateFollows() throws Exception { + // Create a remote actor + RemoteActor remoteActor = RemoteActor.builder() + .actorUri("https://remote.example/users/grace") + .username("grace") + .domain("remote.example") + .displayName("Grace Remote") + .inboxUrl("https://remote.example/users/grace/inbox") + .outboxUrl("https://remote.example/users/grace/outbox") + .publicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----") + .lastFetchedAt(Instant.now()) + .build(); + remoteActor = remoteActorRepository.save(remoteActor); + + // Create existing follow + Follow existingFollow = Follow.builder() + .followerId(testUser.getId()) + .followingActorUri(remoteActor.getActorUri()) + .status(Follow.FollowStatus.ACCEPTED) + .build(); + followRepository.save(existingFollow); + + // Try to follow again - should get appropriate response + String remoteHandle = "@grace@remote.example"; + + mockMvc.perform(post("/api/users/" + remoteHandle + "/follow") + .header("Authorization", "Bearer " + authToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()); // Should return error for duplicate follow + } +} diff --git a/src/test/java/org/operaton/fitpub/service/TrainingLoadServiceTest.java b/src/test/java/org/operaton/fitpub/service/TrainingLoadServiceTest.java index a8369c0..8add6da 100644 --- a/src/test/java/org/operaton/fitpub/service/TrainingLoadServiceTest.java +++ b/src/test/java/org/operaton/fitpub/service/TrainingLoadServiceTest.java @@ -280,19 +280,27 @@ class TrainingLoadServiceTest { // Given int days = 30; LocalDate startDate = LocalDate.now().minusDays(days - 1); - List expectedLoad = List.of( + List existingLoad = List.of( createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0)) ); when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate)) - .thenReturn(expectedLoad); + .thenReturn(existingLoad); // When List result = trainingLoadService.getRecentTrainingLoad(userId, days); // Then - assertEquals(expectedLoad, result); + // Should return 30 days of data (fills in missing days with rest day entries) + assertEquals(30, result.size()); verify(trainingLoadRepository).findByUserIdSinceDate(userId, startDate); + + // Verify that the existing load is included + assertTrue(result.stream().anyMatch(tl -> + tl.getDate().equals(testDate) && + tl.getTrainingStressScore() != null && + tl.getTrainingStressScore().compareTo(BigDecimal.valueOf(100.0)) == 0 + )); } @Test diff --git a/src/test/java/org/operaton/fitpub/service/WebFingerClientTest.java b/src/test/java/org/operaton/fitpub/service/WebFingerClientTest.java new file mode 100644 index 0000000..8e1d8b6 --- /dev/null +++ b/src/test/java/org/operaton/fitpub/service/WebFingerClientTest.java @@ -0,0 +1,217 @@ +package org.operaton.fitpub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for WebFingerClient. + */ +@ExtendWith(MockitoExtension.class) +class WebFingerClientTest { + + private WebFingerClient webFingerClient; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + webFingerClient = new WebFingerClient(objectMapper); + ReflectionTestUtils.setField(webFingerClient, "localDomain", "fitpub.test"); + } + + // ==================== Handle Parsing Tests ==================== + + @Test + void parseHandle_withAtPrefix_shouldParseCorrectly() throws Exception { + // This test uses reflection to access the private parseHandle method + String handle = "@alice@example.com"; + + // We can't directly test private methods, but we can test through discoverActor + // which will validate the handle parsing logic + // For now, we'll test the validation through discoverActor's exceptions + + // Testing valid format doesn't throw during parsing phase + assertThatThrownBy(() -> webFingerClient.discoverActor(handle)) + .isInstanceOf(IOException.class) // Will fail at network call, but parsing succeeded + .hasMessageContaining("Failed to fetch WebFinger resource"); + } + + @Test + void parseHandle_withoutAtPrefix_shouldParseCorrectly() throws Exception { + String handle = "alice@example.com"; + + assertThatThrownBy(() -> webFingerClient.discoverActor(handle)) + .isInstanceOf(IOException.class) // Will fail at network call, but parsing succeeded + .hasMessageContaining("Failed to fetch WebFinger resource"); + } + + @Test + void parseHandle_withNullHandle_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Handle cannot be null or empty"); + } + + @Test + void parseHandle_withEmptyHandle_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Handle cannot be null or empty"); + } + + @Test + void parseHandle_withBlankHandle_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Handle cannot be null or empty"); + } + + @Test + void parseHandle_withoutAtSymbol_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("aliceexample.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid handle format"); + } + + @Test + void parseHandle_withMultipleAtSymbols_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("@alice@example@com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid handle format"); + } + + @Test + void parseHandle_withEmptyUsername_shouldThrowException() { + // "@example.com" becomes "example.com" after removing @, then split gives only 1 part + assertThatThrownBy(() -> webFingerClient.discoverActor("@example.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid handle format"); + } + + @Test + void parseHandle_withEmptyDomain_shouldThrowException() { + // "alice@" splits into ["alice"] (trailing empty string is discarded) + // So this fails the parts.length != 2 check + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid handle format"); + } + + @Test + void parseHandle_withInvalidUsernameCharacters_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice!@example.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid username format"); + } + + @Test + void parseHandle_withInvalidDomainFormat_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@invalid")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid domain format"); + } + + @Test + void parseHandle_withValidUsernameCharacters_shouldNotThrowParsingException() { + // Valid characters: a-z, A-Z, 0-9, _, - + assertThatThrownBy(() -> webFingerClient.discoverActor("alice_bob-123@example.com")) + .isInstanceOf(IOException.class) // Fails at network, not parsing + .hasMessageContaining("Failed to fetch WebFinger resource"); + } + + // ==================== SSRF Protection Tests ==================== + + @Test + void validateDomain_withLoopbackAddress_shouldThrowException() { + // "localhost" doesn't have a dot, so it fails domain format validation + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@localhost")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid domain format"); + } + + @Test + void validateDomain_with127_0_0_1_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@127.0.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Loopback addresses are not allowed"); + } + + @Test + void validateDomain_withPrivateIP_10_0_0_1_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@10.0.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Private IP addresses are not allowed"); + } + + @Test + void validateDomain_withPrivateIP_192_168_1_1_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@192.168.1.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Private IP addresses are not allowed"); + } + + @Test + void validateDomain_withPrivateIP_172_16_0_1_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@172.16.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Private IP addresses are not allowed"); + } + + @Test + void validateDomain_withLinkLocalAddress_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@169.254.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Link-local addresses are not allowed"); + } + + @Test + void validateDomain_withLocalDomain_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@fitpub.test")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot discover local users via WebFinger"); + } + + @Test + void validateDomain_withLocalDomainCaseInsensitive_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@FITPUB.TEST")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot discover local users via WebFinger"); + } + + @Test + void validateDomain_withNonexistentDomain_shouldThrowException() { + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@this-domain-does-not-exist-12345.invalid")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to resolve domain"); + } + + // ==================== Integration-like Tests ==================== + // Note: These tests will attempt real network calls and will fail with IOException + // In a real scenario, we'd use WireMock or similar to mock HTTP responses + + @Test + void discoverActor_withValidHandle_butNoNetwork_shouldThrowIOException() { + // This test validates that valid handles pass validation + // Use a domain that definitely won't have WebFinger endpoint + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@example.com")) + .isInstanceOf(IOException.class) + .hasMessageContaining("Failed to fetch WebFinger resource"); + } + + @Test + void discoverActor_withPublicIP_shouldPassSSRFValidation() { + // Public IP (Google DNS) should pass SSRF validation but fail at WebFinger layer + assertThatThrownBy(() -> webFingerClient.discoverActor("alice@8.8.8.8")) + .isInstanceOf(IOException.class) + .hasMessageContaining("Failed to fetch WebFinger resource"); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..8df8581 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,52 @@ +spring: + application: + name: fitpub-test + + # Testcontainers will automatically configure the datasource + datasource: + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + url: jdbc:tc:postgis:13-3.1:///testdb?TC_INITSCRIPT=file:src/test/resources/init-test-db.sql + + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.spatial.dialect.postgis.PostgisPG10Dialect + format_sql: true + show-sql: false + + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + +fitpub: + domain: localhost:8080 + base-url: http://localhost:8080 + activitypub: + enabled: true + max-federation-retries: 3 + security: + jwt: + secret: test-secret-key-for-jwt-token-generation-in-tests-must-be-long-enough + expiration: 86400000 # 24 hours + storage: + fit-files: + path: ${java.io.tmpdir}/fitpub-test/fit-files + retention-days: 365 + weather: + enabled: false + api-key: test-api-key + +logging: + level: + org.operaton.fitpub: DEBUG + org.springframework: WARN + org.hibernate: WARN + org.testcontainers: INFO diff --git a/src/test/resources/init-test-db.sql b/src/test/resources/init-test-db.sql new file mode 100644 index 0000000..0a2dc09 --- /dev/null +++ b/src/test/resources/init-test-db.sql @@ -0,0 +1,2 @@ +-- Initialize PostGIS extension for test database +CREATE EXTENSION IF NOT EXISTS postgis;