Better Federation Support

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

516
FEDERATION_TESTING_GUIDE.md Normal file
View file

@ -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 <<EOF
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "http://localhost:8081/activities/create/test-123",
"actor": "http://localhost:8081/users/bob",
"published": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["http://localhost:8081/users/bob/followers"],
"object": {
"type": "Note",
"id": "http://localhost:8081/workouts/test-456",
"attributedTo": "http://localhost:8081/users/bob",
"name": "Test Workout via ActivityPub",
"content": "Testing direct ActivityPub activity delivery",
"published": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"workoutData": {
"activityType": "RUN",
"distance": 5000,
"duration": "PT25M30S",
"elevationGain": 50
},
"attachment": [
{
"type": "Document",
"mediaType": "image/png",
"name": "Map",
"url": "http://localhost:8081/activities/test-456/map.png"
}
]
}
}
EOF
# Post to Alice's inbox on Instance 1
curl -X POST http://localhost:8080/users/alice/inbox \
-H "Content-Type: application/activity+json" \
-d @test-activity.json
# Expected response: 202 Accepted
```
**Verify in database:**
```sql
-- On Instance 1
SELECT * FROM remote_activities WHERE remote_actor_uri = 'http://localhost:8081/users/bob';
```
### Test 8: Unfollow Workflow
**Alice unfollows Bob:**
1. On Instance 1, navigate to http://localhost:8080/users/alice/following
2. Find Bob in the following list
3. Click "Unfollow"
4. Verify confirmation dialog
5. Confirm unfollow
**Verify Undo Activity:**
Check Instance 2 logs for incoming Undo activity:
```
Processing Undo activity for user bob
Deleted follow from actor: http://localhost:8080/users/alice
```
**Check Database:**
```sql
-- On Instance 2
SELECT * FROM follows WHERE remote_actor_uri = 'http://localhost:8080/users/alice'
AND following_actor_uri = 'http://localhost:8081/users/bob';
-- Should return 0 rows
```
### Test 9: Accept Activity Flow
**Bob follows Alice (reverse direction):**
1. On Instance 2, login as Bob
2. Navigate to http://localhost:8081/discover
3. Search for: `alice@localhost:8080`
4. Click "Follow"
5. Verify "Follow request sent" notification
**Check Follow Status:**
```sql
-- On Instance 2
SELECT status FROM follows WHERE follower_id = (SELECT id FROM users WHERE username = 'bob')
AND following_actor_uri = 'http://localhost:8080/users/alice';
-- Should return 'PENDING'
```
**Verify Accept on Instance 1:**
Check Instance 1 logs for outgoing Accept activity:
```
Sending Accept activity to http://localhost:8081/users/bob/inbox
Accept activity sent successfully
```
**Check Updated Status:**
```sql
-- On Instance 2
SELECT status FROM follows WHERE follower_id = (SELECT id FROM users WHERE username = 'bob')
AND following_actor_uri = 'http://localhost:8080/users/alice';
-- Should return 'ACCEPTED'
```
**Check Notification:**
On Instance 2:
1. Navigate to http://localhost:8081/notifications
2. Verify notification: "alice@localhost:8080 accepted your follow request"
## Troubleshooting
### Instance Won't Start
**Problem:** Port already in use
```
Port 8080 is already in use
```
**Solution:** Kill the process using the port or use a different port
```bash
lsof -ti:8080 | xargs kill -9
```
### Database Connection Error
**Problem:** Connection refused to PostgreSQL
**Solution:** Check PostgreSQL is running
```bash
brew services start postgresql
# or
sudo systemctl start postgresql
```
### WebFinger Not Working
**Problem:** 404 when accessing /.well-known/webfinger
**Solution:**
1. Check if the controller is mapped correctly
2. Verify Spring Security allows unauthenticated access to WebFinger endpoint
3. Check logs for any errors
### Remote Activities Not Appearing
**Problem:** Bob's activities don't show up in Alice's federated timeline
**Solution:**
1. Verify follow relationship exists and status is ACCEPTED:
```sql
SELECT * FROM follows WHERE follower_id = (SELECT id FROM users WHERE username = 'alice')
AND following_actor_uri LIKE '%bob%';
```
2. Check InboxProcessor logs for incoming Create activities
3. Verify RemoteActivity was created:
```sql
SELECT * FROM remote_activities;
```
4. Check TimelineService is fetching both local and remote activities
### Map Preview Not Loading
**Problem:** Remote activity map shows "Map not available"
**Solution:**
- Remote activities use `mapImageUrl` field which must be set when creating the activity
- For testing, you may need to implement map image generation on the origin server
- Check if the URL in `map_image_url` is accessible
## Validation Checklist
- [ ] Both instances start successfully on different ports
- [ ] WebFinger discovery works in both directions
- [ ] Remote user discovery UI works
- [ ] Follow request is sent and creates PENDING follow
- [ ] Accept activity is received and updates status to ACCEPTED
- [ ] Follower/following lists show both local and remote users
- [ ] Remote activities appear in federated timeline
- [ ] Remote activities show federation badge
- [ ] Map preview loads from remote server
- [ ] "View on Origin Server" opens correct URL
- [ ] Unfollow sends Undo activity and removes follow
- [ ] Notifications are created for follow/accept events
- [ ] No errors in console logs
## Database Inspection Queries
### Check All Follows
```sql
-- On any instance
SELECT
f.id,
f.follower_id,
u.username as follower_username,
f.following_actor_uri,
f.remote_actor_uri,
f.status,
f.created_at
FROM follows f
LEFT JOIN users u ON f.follower_id = u.id
ORDER BY f.created_at DESC;
```
### Check Remote Actors
```sql
SELECT
actor_uri,
username,
domain,
display_name,
last_fetched
FROM remote_actors
ORDER BY last_fetched DESC;
```
### Check Remote Activities
```sql
SELECT
id,
activity_uri,
remote_actor_uri,
activity_type,
title,
total_distance,
total_duration_seconds,
published_at,
visibility,
map_image_url
FROM remote_activities
ORDER BY published_at DESC;
```
## Clean Up
To reset the test environment:
```bash
# Stop both instances (Ctrl+C in both terminals)
# Drop and recreate databases
psql -U postgres <<EOF
DROP DATABASE fitpub_instance1;
DROP DATABASE fitpub_instance2;
CREATE DATABASE fitpub_instance1;
CREATE DATABASE fitpub_instance2;
\c fitpub_instance1
CREATE EXTENSION IF NOT EXISTS postgis;
\c fitpub_instance2
CREATE EXTENSION IF NOT EXISTS postgis;
EOF
```
## Additional Testing Tips
1. **Use Browser Developer Tools** to inspect network requests for ActivityPub activities
2. **Monitor Logs** in both terminal windows to see federation events in real-time
3. **Test Edge Cases** like following yourself, duplicate follows, following non-existent users
4. **Test Different Visibility Levels** (PUBLIC, FOLLOWERS, PRIVATE)
5. **Simulate Network Failures** by stopping one instance mid-federation
6. **Test Concurrent Operations** (both users following each other simultaneously)
## Success Criteria
✅ All checklist items pass
✅ No errors in logs
✅ Activities federate within 1-2 seconds
✅ UI updates reflect federation state correctly
✅ Database state is consistent across both instances

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -280,19 +280,27 @@ class TrainingLoadServiceTest {
// Given
int days = 30;
LocalDate startDate = LocalDate.now().minusDays(days - 1);
List<TrainingLoad> expectedLoad = List.of(
List<TrainingLoad> existingLoad = List.of(
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
);
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
.thenReturn(expectedLoad);
.thenReturn(existingLoad);
// When
List<TrainingLoad> 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

View file

@ -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");
}
}

View file

@ -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

View file

@ -0,0 +1,2 @@
-- Initialize PostGIS extension for test database
CREATE EXTENSION IF NOT EXISTS postgis;