Better Federation Support
This commit is contained in:
parent
15b420b87a
commit
5b687883b0
22 changed files with 2931 additions and 49 deletions
516
FEDERATION_TESTING_GUIDE.md
Normal file
516
FEDERATION_TESTING_GUIDE.md
Normal 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
|
||||
|
|
@ -121,6 +121,7 @@ public class SecurityConfig {
|
|||
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/discover-remote").authenticated() // Remote user discovery
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ public class UserController {
|
|||
private final UserRepository userRepository;
|
||||
private final FollowRepository followRepository;
|
||||
private final RemoteActorRepository remoteActorRepository;
|
||||
private final org.operaton.fitpub.service.WebFingerClient webFingerClient;
|
||||
private final org.operaton.fitpub.service.FederationService federationService;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
|
@ -198,6 +200,44 @@ public class UserController {
|
|||
return ResponseEntity.ok(userDTOs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a remote user via WebFinger.
|
||||
* Takes a handle in the format @username@domain or username@domain,
|
||||
* performs WebFinger discovery, fetches the remote actor, and returns actor information.
|
||||
*
|
||||
* @param handle the handle of the remote user (@username@domain)
|
||||
* @param userDetails the authenticated user making the request
|
||||
* @return ActorDTO containing remote user information
|
||||
*/
|
||||
@GetMapping("/discover-remote")
|
||||
public ResponseEntity<ActorDTO> discoverRemoteUser(
|
||||
@RequestParam String handle,
|
||||
@AuthenticationPrincipal UserDetails userDetails
|
||||
) {
|
||||
log.info("User {} discovering remote user: {}", userDetails.getUsername(), handle);
|
||||
|
||||
try {
|
||||
// 1. WebFinger lookup to discover actor URI
|
||||
String actorUri = webFingerClient.discoverActor(handle);
|
||||
log.debug("Discovered actor URI: {}", actorUri);
|
||||
|
||||
// 2. Fetch remote actor information
|
||||
RemoteActor remoteActor = federationService.fetchRemoteActor(actorUri);
|
||||
log.debug("Fetched remote actor: {}", remoteActor.getUsername());
|
||||
|
||||
// 3. Convert to DTO and return (no follow relationship yet, so followedAt is null)
|
||||
ActorDTO dto = ActorDTO.fromRemoteActor(remoteActor, null);
|
||||
return ResponseEntity.ok(dto);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid handle format: {}", handle, e);
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error discovering remote user: {}", handle, e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of followers for a user.
|
||||
*
|
||||
|
|
@ -275,9 +315,9 @@ public class UserController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Follow a user.
|
||||
* Follow a user (local or remote).
|
||||
*
|
||||
* @param username the username to follow
|
||||
* @param username the username to follow (local username or @username@domain format)
|
||||
* @param userDetails the authenticated user
|
||||
* @return success response
|
||||
*/
|
||||
|
|
@ -292,6 +332,22 @@ public class UserController {
|
|||
User currentUser = userRepository.findByUsername(userDetails.getUsername())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("Current user not found"));
|
||||
|
||||
// Check if this is a remote user (contains @ and position > 0)
|
||||
boolean isRemoteUser = username.contains("@") && username.indexOf("@") > 0;
|
||||
|
||||
if (isRemoteUser) {
|
||||
// Remote user follow
|
||||
return followRemoteUser(username, currentUser);
|
||||
} else {
|
||||
// Local user follow
|
||||
return followLocalUser(username, currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a local user (auto-accepted).
|
||||
*/
|
||||
private ResponseEntity<Map<String, Object>> followLocalUser(String username, User currentUser) {
|
||||
// Get the user to follow
|
||||
User userToFollow = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
|
@ -342,6 +398,56 @@ public class UserController {
|
|||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a remote user via ActivityPub (requires Accept from remote).
|
||||
*/
|
||||
private ResponseEntity<Map<String, Object>> followRemoteUser(String handle, User currentUser) {
|
||||
try {
|
||||
log.info("Following remote user: {}", handle);
|
||||
|
||||
// 1. Discover remote actor using WebFinger
|
||||
String remoteActorUri = webFingerClient.discoverActor(handle);
|
||||
log.debug("Discovered remote actor URI: {}", remoteActorUri);
|
||||
|
||||
// 2. Check if already following
|
||||
Optional<Follow> existingFollow = followRepository.findByFollowerIdAndFollowingActorUri(
|
||||
currentUser.getId(), remoteActorUri
|
||||
);
|
||||
|
||||
if (existingFollow.isPresent()) {
|
||||
Follow follow = existingFollow.get();
|
||||
if (follow.getStatus() == Follow.FollowStatus.ACCEPTED) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Already following this user"));
|
||||
} else {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Follow request already pending for " + handle,
|
||||
"status", "PENDING"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send Follow activity to remote actor
|
||||
// This will also create a PENDING follow record
|
||||
federationService.sendFollowActivity(remoteActorUri, currentUser);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Follow request sent to " + handle,
|
||||
"status", "PENDING",
|
||||
"note", "Waiting for acceptance from remote user"
|
||||
));
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid handle format: {}", handle, e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Invalid handle format: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to follow remote user: {}", handle, e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(Map.of("error", "Failed to follow remote user: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfollow a user.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
package org.operaton.fitpub.model.activitypub;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Represents a link in a WebFinger response (RFC 7033).
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "rel": "self",
|
||||
* "type": "application/activity+json",
|
||||
* "href": "https://fitpub.example/users/username"
|
||||
* }
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class WebFingerLink {
|
||||
|
||||
/**
|
||||
* The relationship type (e.g., "self", "http://webfinger.net/rel/profile-page").
|
||||
*/
|
||||
private String rel;
|
||||
|
||||
/**
|
||||
* The media type of the linked resource (e.g., "application/activity+json").
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* The URL of the linked resource.
|
||||
*/
|
||||
private String href;
|
||||
|
||||
/**
|
||||
* Optional template for the link (used for some link types).
|
||||
*/
|
||||
private String template;
|
||||
|
||||
/**
|
||||
* Optional titles for the link in different languages.
|
||||
*/
|
||||
private java.util.Map<String, String> titles;
|
||||
|
||||
/**
|
||||
* Optional properties for the link.
|
||||
*/
|
||||
private java.util.Map<String, String> properties;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package org.operaton.fitpub.model.activitypub;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Represents a WebFinger resource response (RFC 7033).
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "subject": "acct:username@domain.com",
|
||||
* "aliases": ["https://domain.com/users/username"],
|
||||
* "links": [
|
||||
* {
|
||||
* "rel": "self",
|
||||
* "type": "application/activity+json",
|
||||
* "href": "https://domain.com/users/username"
|
||||
* },
|
||||
* {
|
||||
* "rel": "http://webfinger.net/rel/profile-page",
|
||||
* "type": "text/html",
|
||||
* "href": "https://domain.com/@username"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class WebFingerResource {
|
||||
|
||||
/**
|
||||
* The subject identifier (usually in acct: URI format).
|
||||
* Example: "acct:username@domain.com"
|
||||
*/
|
||||
private String subject;
|
||||
|
||||
/**
|
||||
* Alternative URIs for the same resource.
|
||||
*/
|
||||
private List<String> aliases;
|
||||
|
||||
/**
|
||||
* Links to related resources.
|
||||
*/
|
||||
private List<WebFingerLink> links;
|
||||
|
||||
/**
|
||||
* Optional properties (key-value pairs).
|
||||
*/
|
||||
private Map<String, String> properties;
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@ import lombok.Builder;
|
|||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.RemoteActivity;
|
||||
import org.operaton.fitpub.model.entity.RemoteActor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
|
|
@ -38,6 +41,10 @@ public class TimelineActivityDTO {
|
|||
private String avatarUrl;
|
||||
private boolean isLocal;
|
||||
|
||||
// Remote activity fields (only populated for federated activities)
|
||||
private String activityUri; // Full ActivityPub URI (for remote activities)
|
||||
private String mapImageUrl; // Map image URL (for remote activities)
|
||||
|
||||
// Social interaction counts
|
||||
private Long likesCount;
|
||||
private Long commentsCount;
|
||||
|
|
@ -71,6 +78,45 @@ public class TimelineActivityDTO {
|
|||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RemoteActivity entity to timeline DTO.
|
||||
* Used for displaying federated activities from other FitPub instances.
|
||||
*/
|
||||
public static TimelineActivityDTO fromRemoteActivity(RemoteActivity remote, RemoteActor actor) {
|
||||
// Create metrics summary from remote activity fields
|
||||
ActivityMetricsSummary metrics = ActivityMetricsSummary.builder()
|
||||
.averageHeartRate(remote.getAverageHeartRate())
|
||||
.averageSpeed(remote.getAverageSpeed())
|
||||
.maxSpeed(remote.getMaxSpeed())
|
||||
.averagePaceSeconds(remote.getAveragePaceSeconds())
|
||||
.calories(remote.getCalories())
|
||||
.build();
|
||||
|
||||
return TimelineActivityDTO.builder()
|
||||
.id(remote.getId())
|
||||
.activityType(remote.getActivityType() != null ? remote.getActivityType() : "UNKNOWN")
|
||||
.title(remote.getTitle())
|
||||
.description(remote.getDescription())
|
||||
.startedAt(remote.getPublishedAt() != null
|
||||
? LocalDateTime.ofInstant(remote.getPublishedAt(), ZoneId.systemDefault())
|
||||
: null)
|
||||
.endedAt(null) // Not available for remote activities
|
||||
.totalDistance(remote.getTotalDistance() != null ? remote.getTotalDistance().doubleValue() : null)
|
||||
.totalDurationSeconds(remote.getTotalDurationSeconds())
|
||||
.elevationGain(remote.getElevationGain() != null ? remote.getElevationGain().doubleValue() : null)
|
||||
.elevationLoss(null) // Not available for remote activities
|
||||
.visibility(remote.getVisibility() != null ? remote.getVisibility().name() : "PUBLIC")
|
||||
.createdAt(remote.getCreatedAt())
|
||||
.username(actor.getUsername())
|
||||
.displayName(actor.getDisplayName() != null ? actor.getDisplayName() : actor.getUsername())
|
||||
.avatarUrl(actor.getAvatarUrl())
|
||||
.isLocal(false)
|
||||
.activityUri(remote.getActivityUri())
|
||||
.mapImageUrl(remote.getMapImageUrl())
|
||||
.metrics(metrics)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of activity metrics for timeline display.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing a remote fitness activity from another FitPub instance or ActivityPub server.
|
||||
*
|
||||
* IMPORTANT: This entity stores METADATA ONLY - no full track data.
|
||||
* Track visualization is done via remote map image URLs pointing to the origin server.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "remote_activities", indexes = {
|
||||
@Index(name = "idx_remote_activity_uri", columnList = "activity_uri", unique = true),
|
||||
@Index(name = "idx_remote_activity_actor", columnList = "remote_actor_uri"),
|
||||
@Index(name = "idx_remote_activity_published", columnList = "published_at"),
|
||||
@Index(name = "idx_remote_activity_visibility", columnList = "visibility")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RemoteActivity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private UUID id;
|
||||
|
||||
/**
|
||||
* ActivityPub activity URI (globally unique identifier).
|
||||
* Example: https://fitpub.example/activities/12345
|
||||
*/
|
||||
@Column(name = "activity_uri", nullable = false, unique = true, length = 512)
|
||||
private String activityUri;
|
||||
|
||||
/**
|
||||
* ActivityPub actor URI of the user who created this activity.
|
||||
* Example: https://fitpub.example/users/alice
|
||||
*/
|
||||
@Column(name = "remote_actor_uri", nullable = false, length = 512)
|
||||
private String remoteActorUri;
|
||||
|
||||
/**
|
||||
* Type of activity (RUN, RIDE, HIKE, SWIM, etc.).
|
||||
*/
|
||||
@Column(name = "activity_type", length = 50)
|
||||
private String activityType;
|
||||
|
||||
/**
|
||||
* Activity title.
|
||||
*/
|
||||
@Column(name = "title", nullable = false, length = 500)
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* Activity description/notes.
|
||||
*/
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* When the activity was published (from ActivityPub).
|
||||
*/
|
||||
@Column(name = "published_at", nullable = false)
|
||||
private Instant publishedAt;
|
||||
|
||||
/**
|
||||
* Total distance in meters.
|
||||
*/
|
||||
@Column(name = "total_distance")
|
||||
private Long totalDistance;
|
||||
|
||||
/**
|
||||
* Total duration in seconds.
|
||||
*/
|
||||
@Column(name = "total_duration_seconds")
|
||||
private Long totalDurationSeconds;
|
||||
|
||||
/**
|
||||
* Total elevation gain in meters.
|
||||
*/
|
||||
@Column(name = "elevation_gain")
|
||||
private Integer elevationGain;
|
||||
|
||||
/**
|
||||
* Average pace in seconds per kilometer (for runs).
|
||||
*/
|
||||
@Column(name = "average_pace_seconds")
|
||||
private Long averagePaceSeconds;
|
||||
|
||||
/**
|
||||
* Average heart rate in BPM.
|
||||
*/
|
||||
@Column(name = "average_heart_rate")
|
||||
private Integer averageHeartRate;
|
||||
|
||||
/**
|
||||
* Maximum speed in km/h.
|
||||
*/
|
||||
@Column(name = "max_speed")
|
||||
private Double maxSpeed;
|
||||
|
||||
/**
|
||||
* Average speed in km/h.
|
||||
*/
|
||||
@Column(name = "average_speed")
|
||||
private Double averageSpeed;
|
||||
|
||||
/**
|
||||
* Calories burned (if provided).
|
||||
*/
|
||||
@Column(name = "calories")
|
||||
private Integer calories;
|
||||
|
||||
/**
|
||||
* URL to the map image on the remote server.
|
||||
* This points to a static map image generated by the origin server.
|
||||
*/
|
||||
@Column(name = "map_image_url", length = 512)
|
||||
private String mapImageUrl;
|
||||
|
||||
/**
|
||||
* URL to the GeoJSON track data on the remote server (optional).
|
||||
* If provided, allows dynamic map rendering.
|
||||
*/
|
||||
@Column(name = "track_geojson_url", length = 512)
|
||||
private String trackGeojsonUrl;
|
||||
|
||||
/**
|
||||
* Visibility level of the activity.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "visibility", nullable = false, length = 20)
|
||||
private Visibility visibility;
|
||||
|
||||
/**
|
||||
* Full ActivityPub object as JSON (for future re-parsing or debugging).
|
||||
* Stored as JSONB for efficient querying.
|
||||
*/
|
||||
@Type(JsonBinaryType.class)
|
||||
@Column(name = "activitypub_object", columnDefinition = "jsonb")
|
||||
private String activityPubObject;
|
||||
|
||||
/**
|
||||
* Timestamp when this record was created locally.
|
||||
*/
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Timestamp when this record was last updated.
|
||||
*/
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Visibility levels for remote activities.
|
||||
*/
|
||||
public enum Visibility {
|
||||
/**
|
||||
* Public activity visible to everyone.
|
||||
*/
|
||||
PUBLIC,
|
||||
|
||||
/**
|
||||
* Activity visible only to followers.
|
||||
*/
|
||||
FOLLOWERS,
|
||||
|
||||
/**
|
||||
* Private activity (should not be federated, but included for completeness).
|
||||
*/
|
||||
PRIVATE
|
||||
}
|
||||
}
|
||||
|
|
@ -67,4 +67,14 @@ public interface FollowRepository extends JpaRepository<Follow, UUID> {
|
|||
* @return the follow relationship if it exists
|
||||
*/
|
||||
Optional<Follow> findByActivityId(String activityId);
|
||||
|
||||
/**
|
||||
* Find a follow relationship by remote actor URI and following actor URI.
|
||||
* Used to check if a remote user is following a local user.
|
||||
*
|
||||
* @param remoteActorUri the remote actor's URI (follower)
|
||||
* @param followingActorUri the actor URI being followed
|
||||
* @return the follow relationship if it exists
|
||||
*/
|
||||
Optional<Follow> findByRemoteActorUriAndFollowingActorUri(String remoteActorUri, String followingActorUri);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.RemoteActivity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for RemoteActivity entity.
|
||||
* Provides methods for querying remote fitness activities from federated instances.
|
||||
*/
|
||||
@Repository
|
||||
public interface RemoteActivityRepository extends JpaRepository<RemoteActivity, UUID> {
|
||||
|
||||
/**
|
||||
* Finds a remote activity by its ActivityPub URI.
|
||||
* Used for deduplication and activity lookup.
|
||||
*
|
||||
* @param activityUri the ActivityPub activity URI
|
||||
* @return optional remote activity
|
||||
*/
|
||||
Optional<RemoteActivity> findByActivityUri(String activityUri);
|
||||
|
||||
/**
|
||||
* Checks if a remote activity exists by its ActivityPub URI.
|
||||
* Used for deduplication before storing.
|
||||
*
|
||||
* @param activityUri the ActivityPub activity URI
|
||||
* @return true if exists
|
||||
*/
|
||||
boolean existsByActivityUri(String activityUri);
|
||||
|
||||
/**
|
||||
* Finds remote activities by a specific remote actor.
|
||||
*
|
||||
* @param remoteActorUri the remote actor URI
|
||||
* @param pageable pagination parameters
|
||||
* @return page of remote activities
|
||||
*/
|
||||
Page<RemoteActivity> findByRemoteActorUri(String remoteActorUri, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Finds remote activities from multiple actors with specific visibility levels.
|
||||
* Used for federated timeline - shows activities from users you follow.
|
||||
*
|
||||
* @param actorUris list of remote actor URIs
|
||||
* @param visibilities list of allowed visibility levels (PUBLIC, FOLLOWERS)
|
||||
* @param pageable pagination parameters
|
||||
* @return page of remote activities
|
||||
*/
|
||||
@Query("SELECT ra FROM RemoteActivity ra WHERE ra.remoteActorUri IN :actorUris " +
|
||||
"AND ra.visibility IN :visibilities " +
|
||||
"ORDER BY ra.publishedAt DESC")
|
||||
Page<RemoteActivity> findByRemoteActorUriInAndVisibilityIn(
|
||||
@Param("actorUris") List<String> actorUris,
|
||||
@Param("visibilities") List<RemoteActivity.Visibility> visibilities,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* Finds all public remote activities.
|
||||
* Used for public timeline.
|
||||
*
|
||||
* @param pageable pagination parameters
|
||||
* @return page of public remote activities
|
||||
*/
|
||||
@Query("SELECT ra FROM RemoteActivity ra WHERE ra.visibility = 'PUBLIC' " +
|
||||
"ORDER BY ra.publishedAt DESC")
|
||||
Page<RemoteActivity> findAllPublicActivities(Pageable pageable);
|
||||
|
||||
/**
|
||||
* Counts remote activities from a specific actor.
|
||||
*
|
||||
* @param remoteActorUri the remote actor URI
|
||||
* @return count of activities
|
||||
*/
|
||||
long countByRemoteActorUri(String remoteActorUri);
|
||||
|
||||
/**
|
||||
* Finds activities by type from specific actors.
|
||||
* Used for filtering by activity type.
|
||||
*
|
||||
* @param actorUris list of remote actor URIs
|
||||
* @param activityType the activity type (RUN, RIDE, etc.)
|
||||
* @param pageable pagination parameters
|
||||
* @return page of activities
|
||||
*/
|
||||
@Query("SELECT ra FROM RemoteActivity ra WHERE ra.remoteActorUri IN :actorUris " +
|
||||
"AND ra.activityType = :activityType " +
|
||||
"ORDER BY ra.publishedAt DESC")
|
||||
Page<RemoteActivity> findByRemoteActorUriInAndActivityType(
|
||||
@Param("actorUris") List<String> actorUris,
|
||||
@Param("activityType") String activityType,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes all remote activities from a specific actor.
|
||||
* Used when unfollowing or blocking a remote user.
|
||||
*
|
||||
* @param remoteActorUri the remote actor URI
|
||||
*/
|
||||
void deleteByRemoteActorUri(String remoteActorUri);
|
||||
}
|
||||
|
|
@ -119,6 +119,53 @@ public class FederationService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Follow activity to a remote actor.
|
||||
*
|
||||
* @param remoteActorUri the URI of the remote actor to follow
|
||||
* @param localUser the local user initiating the follow
|
||||
*/
|
||||
@Transactional
|
||||
public void sendFollowActivity(String remoteActorUri, User localUser) {
|
||||
try {
|
||||
log.info("Sending Follow activity from {} to {}", localUser.getUsername(), remoteActorUri);
|
||||
|
||||
// 1. Fetch remote actor to get inbox URL and cache their info
|
||||
RemoteActor remoteActor = fetchRemoteActor(remoteActorUri);
|
||||
|
||||
// 2. Create Follow activity
|
||||
String followId = baseUrl + "/activities/follow/" + UUID.randomUUID();
|
||||
String actorUri = baseUrl + "/users/" + localUser.getUsername();
|
||||
|
||||
Map<String, Object> followActivity = new HashMap<>();
|
||||
followActivity.put("@context", "https://www.w3.org/ns/activitystreams");
|
||||
followActivity.put("type", "Follow");
|
||||
followActivity.put("id", followId);
|
||||
followActivity.put("actor", actorUri);
|
||||
followActivity.put("object", remoteActorUri);
|
||||
followActivity.put("published", Instant.now().toString());
|
||||
|
||||
// 3. Send to remote actor's inbox (HTTP-signed)
|
||||
sendActivity(remoteActor.getInboxUrl(), followActivity, localUser);
|
||||
|
||||
// 4. Create local follow record with PENDING status
|
||||
// The status will be updated to ACCEPTED when we receive an Accept activity
|
||||
Follow follow = Follow.builder()
|
||||
.followerId(localUser.getId())
|
||||
.followingActorUri(remoteActorUri)
|
||||
.status(Follow.FollowStatus.PENDING)
|
||||
.activityId(followId)
|
||||
.build();
|
||||
followRepository.save(follow);
|
||||
|
||||
log.info("Follow activity sent successfully: {} -> {}", localUser.getUsername(), remoteActorUri);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send Follow activity from {} to {}", localUser.getUsername(), remoteActorUri, e);
|
||||
throw new RuntimeException("Failed to send Follow activity", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an Accept activity in response to a Follow.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -35,6 +36,8 @@ public class InboxProcessor {
|
|||
private final LikeRepository likeRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final org.operaton.fitpub.repository.RemoteActivityRepository remoteActivityRepository;
|
||||
private final org.operaton.fitpub.repository.RemoteActorRepository remoteActorRepository;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
|
@ -166,16 +169,40 @@ public class InboxProcessor {
|
|||
private void processAccept(String username, Map<String, Object> activity) {
|
||||
try {
|
||||
Object object = activity.get("object");
|
||||
String activityId = null;
|
||||
|
||||
// Handle both embedded object (Map) and reference (String)
|
||||
if (object instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> acceptObject = (Map<String, Object>) object;
|
||||
String activityId = (String) acceptObject.get("id");
|
||||
activityId = (String) acceptObject.get("id");
|
||||
} else if (object instanceof String) {
|
||||
activityId = (String) object;
|
||||
}
|
||||
|
||||
if (activityId != null) {
|
||||
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
|
||||
if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) {
|
||||
// Update follow status to ACCEPTED
|
||||
follow.setStatus(Follow.FollowStatus.ACCEPTED);
|
||||
followRepository.save(follow);
|
||||
log.info("Follow request accepted: {}", activityId);
|
||||
|
||||
// Create notification for the follower
|
||||
// The follower is the local user who initiated the follow request
|
||||
UUID followerId = follow.getFollowerId();
|
||||
if (followerId != null) {
|
||||
User follower = userRepository.findById(followerId).orElse(null);
|
||||
if (follower != null) {
|
||||
String remoteActorUri = follow.getFollowingActorUri();
|
||||
notificationService.createFollowAcceptedNotification(
|
||||
follower.getId(),
|
||||
remoteActorUri,
|
||||
activityId
|
||||
);
|
||||
log.info("Created follow accepted notification for user {}", follower.getUsername());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
|
@ -206,11 +233,24 @@ public class InboxProcessor {
|
|||
}
|
||||
|
||||
String inReplyTo = (String) noteObject.get("inReplyTo");
|
||||
if (inReplyTo == null) {
|
||||
log.debug("Create/Note is not a reply, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (inReplyTo == null) {
|
||||
// Standalone Note activity - could be a remote workout/activity
|
||||
processRemoteActivity(username, actor, noteObject);
|
||||
} else {
|
||||
// Note with inReplyTo - this is a comment
|
||||
processComment(username, actor, noteObject, inReplyTo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing Create activity", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a comment (Note with inReplyTo).
|
||||
*/
|
||||
private void processComment(String username, String actor, Map<String, Object> noteObject, String inReplyTo) {
|
||||
try {
|
||||
// Extract activity ID from inReplyTo URI
|
||||
UUID activityId = extractActivityIdFromUri(inReplyTo);
|
||||
if (activityId == null) {
|
||||
|
|
@ -260,7 +300,82 @@ public class InboxProcessor {
|
|||
notificationService.createActivityCommentedNotification(localActivity, comment, actor);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing Create activity", e);
|
||||
log.error("Error processing comment", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a remote activity (standalone Note representing a workout/fitness activity).
|
||||
*/
|
||||
private void processRemoteActivity(String username, String actor, Map<String, Object> noteObject) {
|
||||
try {
|
||||
String activityUri = (String) noteObject.get("id");
|
||||
if (activityUri == null) {
|
||||
log.warn("Remote activity has no id");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if activity already exists (deduplication)
|
||||
if (remoteActivityRepository.existsByActivityUri(activityUri)) {
|
||||
log.debug("Remote activity already exists: {}", activityUri);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch and cache remote actor
|
||||
RemoteActor remoteActor = federationService.fetchRemoteActor(actor);
|
||||
|
||||
// Check if local user follows this remote actor
|
||||
User localUser = userRepository.findByUsername(username).orElse(null);
|
||||
if (localUser == null) {
|
||||
log.warn("Local user not found: {}", username);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isFollowing = followRepository.findByFollowerIdAndFollowingActorUri(
|
||||
localUser.getId(), actor
|
||||
).map(follow -> follow.getStatus() == Follow.FollowStatus.ACCEPTED).orElse(false);
|
||||
|
||||
if (!isFollowing) {
|
||||
log.debug("Local user {} is not following {}, ignoring activity", username, actor);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract workout metadata
|
||||
Map<String, Object> workoutData = extractWorkoutData(noteObject);
|
||||
Map<String, String> attachments = extractAttachments(noteObject);
|
||||
org.operaton.fitpub.model.entity.RemoteActivity.Visibility visibility = determineVisibility(noteObject);
|
||||
|
||||
// Parse published timestamp
|
||||
String publishedStr = (String) noteObject.get("published");
|
||||
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
||||
|
||||
// Build RemoteActivity entity
|
||||
org.operaton.fitpub.model.entity.RemoteActivity remoteActivity = org.operaton.fitpub.model.entity.RemoteActivity.builder()
|
||||
.activityUri(activityUri)
|
||||
.remoteActorUri(actor)
|
||||
.activityType((String) workoutData.get("activityType"))
|
||||
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
||||
.description(stripHtml((String) noteObject.get("content")))
|
||||
.publishedAt(publishedAt)
|
||||
.totalDistance(parseLong(workoutData.get("distance")))
|
||||
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
||||
.elevationGain(parseInteger(workoutData.get("elevationGain")))
|
||||
.averagePaceSeconds(parseDurationSeconds((String) workoutData.get("averagePace")))
|
||||
.averageHeartRate(parseInteger(workoutData.get("averageHeartRate")))
|
||||
.maxSpeed(parseDouble(workoutData.get("maxSpeed")))
|
||||
.averageSpeed(parseDouble(workoutData.get("averageSpeed")))
|
||||
.calories(parseInteger(workoutData.get("calories")))
|
||||
.mapImageUrl(attachments.get("mapImage"))
|
||||
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
||||
.visibility(visibility)
|
||||
.activityPubObject(serializeToJson(noteObject))
|
||||
.build();
|
||||
|
||||
remoteActivityRepository.save(remoteActivity);
|
||||
log.info("Stored remote activity from {}: {} ({})", remoteActor.getUsername(), remoteActivity.getTitle(), activityUri);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing remote activity", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -360,4 +475,246 @@ public class InboxProcessor {
|
|||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// ==================== Remote Activity Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Extract workout/fitness data from a Note object.
|
||||
* Looks for a "workoutData" extension field containing structured fitness metrics.
|
||||
*/
|
||||
private Map<String, Object> extractWorkoutData(Map<String, Object> noteObject) {
|
||||
Map<String, Object> workoutData = new java.util.HashMap<>();
|
||||
|
||||
// Check for custom workoutData extension (FitPub-specific)
|
||||
Object workoutDataObj = noteObject.get("workoutData");
|
||||
if (workoutDataObj instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) workoutDataObj;
|
||||
workoutData.putAll(data);
|
||||
}
|
||||
|
||||
// Fallback: Try to extract from summary or content
|
||||
String summary = (String) noteObject.get("summary");
|
||||
if (summary != null) {
|
||||
// Parse summary like "10.2 km • 48:23 • 4:44/km pace"
|
||||
workoutData.putIfAbsent("activityType", guessActivityType(summary));
|
||||
}
|
||||
|
||||
return workoutData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
||||
*/
|
||||
private Map<String, String> extractAttachments(Map<String, Object> noteObject) {
|
||||
Map<String, String> attachments = new java.util.HashMap<>();
|
||||
|
||||
Object attachmentObj = noteObject.get("attachment");
|
||||
if (attachmentObj instanceof java.util.List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Object> attachmentList = (java.util.List<Object>) attachmentObj;
|
||||
|
||||
for (Object item : attachmentList) {
|
||||
if (item instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> attach = (Map<String, Object>) item;
|
||||
|
||||
String type = (String) attach.get("type");
|
||||
String mediaType = (String) attach.get("mediaType");
|
||||
String url = (String) attach.get("url");
|
||||
String name = (String) attach.get("name");
|
||||
|
||||
if (url != null) {
|
||||
// Map image
|
||||
if ("Image".equals(type) && (mediaType != null && mediaType.startsWith("image/"))) {
|
||||
if (name != null && name.toLowerCase().contains("map")) {
|
||||
attachments.put("mapImage", url);
|
||||
}
|
||||
}
|
||||
// GeoJSON track
|
||||
else if ("Document".equals(type) && "application/geo+json".equals(mediaType)) {
|
||||
attachments.put("trackGeojson", url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine visibility from ActivityPub "to" and "cc" fields.
|
||||
*/
|
||||
private org.operaton.fitpub.model.entity.RemoteActivity.Visibility determineVisibility(Map<String, Object> noteObject) {
|
||||
Object toObj = noteObject.get("to");
|
||||
Object ccObj = noteObject.get("cc");
|
||||
|
||||
java.util.List<String> toList = objectToStringList(toObj);
|
||||
java.util.List<String> ccList = objectToStringList(ccObj);
|
||||
|
||||
// Check if Public is in "to" or "cc"
|
||||
boolean isPublic = toList.contains("https://www.w3.org/ns/activitystreams#Public") ||
|
||||
ccList.contains("https://www.w3.org/ns/activitystreams#Public") ||
|
||||
toList.contains("as:Public") ||
|
||||
ccList.contains("as:Public") ||
|
||||
toList.contains("Public") ||
|
||||
ccList.contains("Public");
|
||||
|
||||
if (isPublic) {
|
||||
return org.operaton.fitpub.model.entity.RemoteActivity.Visibility.PUBLIC;
|
||||
}
|
||||
|
||||
// If it has followers in to/cc, it's FOLLOWERS visibility
|
||||
boolean hasFollowers = toList.stream().anyMatch(s -> s.contains("/followers")) ||
|
||||
ccList.stream().anyMatch(s -> s.contains("/followers"));
|
||||
|
||||
if (hasFollowers) {
|
||||
return org.operaton.fitpub.model.entity.RemoteActivity.Visibility.FOLLOWERS;
|
||||
}
|
||||
|
||||
// Default to PRIVATE
|
||||
return org.operaton.fitpub.model.entity.RemoteActivity.Visibility.PRIVATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ISO 8601 duration string (PT48M23S) to seconds.
|
||||
*/
|
||||
private Long parseDurationSeconds(String isoDuration) {
|
||||
if (isoDuration == null || isoDuration.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Simple ISO 8601 duration parser for PT format
|
||||
// Format: PT<hours>H<minutes>M<seconds>S
|
||||
if (!isoDuration.startsWith("PT")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String duration = isoDuration.substring(2); // Remove "PT"
|
||||
long totalSeconds = 0;
|
||||
|
||||
// Parse hours
|
||||
if (duration.contains("H")) {
|
||||
int hIndex = duration.indexOf("H");
|
||||
totalSeconds += Long.parseLong(duration.substring(0, hIndex)) * 3600;
|
||||
duration = duration.substring(hIndex + 1);
|
||||
}
|
||||
|
||||
// Parse minutes
|
||||
if (duration.contains("M")) {
|
||||
int mIndex = duration.indexOf("M");
|
||||
totalSeconds += Long.parseLong(duration.substring(0, mIndex)) * 60;
|
||||
duration = duration.substring(mIndex + 1);
|
||||
}
|
||||
|
||||
// Parse seconds
|
||||
if (duration.contains("S")) {
|
||||
int sIndex = duration.indexOf("S");
|
||||
totalSeconds += Long.parseLong(duration.substring(0, sIndex));
|
||||
}
|
||||
|
||||
return totalSeconds;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse ISO duration: {}", isoDuration, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize object to JSON string.
|
||||
*/
|
||||
private String serializeToJson(Object object) {
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(object);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to serialize object to JSON", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object to list of strings (handles both single string and list).
|
||||
*/
|
||||
private java.util.List<String> objectToStringList(Object obj) {
|
||||
if (obj == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
if (obj instanceof String) {
|
||||
return java.util.Collections.singletonList((String) obj);
|
||||
}
|
||||
if (obj instanceof java.util.List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Object> list = (java.util.List<Object>) obj;
|
||||
return list.stream()
|
||||
.filter(item -> item instanceof String)
|
||||
.map(item -> (String) item)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess activity type from text.
|
||||
*/
|
||||
private String guessActivityType(String text) {
|
||||
if (text == null) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
String lower = text.toLowerCase();
|
||||
if (lower.contains("run") || lower.contains("jog")) return "RUN";
|
||||
if (lower.contains("ride") || lower.contains("bike") || lower.contains("cycl")) return "RIDE";
|
||||
if (lower.contains("hike") || lower.contains("walk")) return "HIKE";
|
||||
if (lower.contains("swim")) return "SWIM";
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Long from object.
|
||||
*/
|
||||
private Long parseLong(Object obj) {
|
||||
if (obj == null) return null;
|
||||
if (obj instanceof Number) return ((Number) obj).longValue();
|
||||
if (obj instanceof String) {
|
||||
try {
|
||||
return Long.parseLong((String) obj);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Integer from object.
|
||||
*/
|
||||
private Integer parseInteger(Object obj) {
|
||||
if (obj == null) return null;
|
||||
if (obj instanceof Number) return ((Number) obj).intValue();
|
||||
if (obj instanceof String) {
|
||||
try {
|
||||
return Integer.parseInt((String) obj);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Double from object.
|
||||
*/
|
||||
private Double parseDouble(Object obj) {
|
||||
if (obj == null) return null;
|
||||
if (obj instanceof Number) return ((Number) obj).doubleValue();
|
||||
if (obj instanceof String) {
|
||||
try {
|
||||
return Double.parseDouble((String) obj);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,41 @@ public class NotificationService {
|
|||
log.debug("Created USER_FOLLOWED notification for user {} from {}", followedUser.getUsername(), actorInfo.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification when a remote user accepts your follow request.
|
||||
*
|
||||
* @param followerId the ID of the user who initiated the follow
|
||||
* @param acceptedActorUri the URI of the remote actor who accepted
|
||||
* @param activityId the ActivityPub activity ID
|
||||
*/
|
||||
public void createFollowAcceptedNotification(UUID followerId, String acceptedActorUri, String activityId) {
|
||||
// Get follower user
|
||||
User follower = userRepository.findById(followerId).orElse(null);
|
||||
if (follower == null) {
|
||||
log.warn("Could not find follower user with ID: {}", followerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get actor information
|
||||
ActorInfo actorInfo = getActorInfo(acceptedActorUri);
|
||||
if (actorInfo == null) {
|
||||
log.warn("Could not find actor info for URI: {}", acceptedActorUri);
|
||||
return;
|
||||
}
|
||||
|
||||
Notification notification = Notification.builder()
|
||||
.user(follower)
|
||||
.type(Notification.NotificationType.FOLLOW_ACCEPTED)
|
||||
.actorUri(acceptedActorUri)
|
||||
.actorDisplayName(actorInfo.displayName)
|
||||
.actorUsername(actorInfo.username)
|
||||
.actorAvatarUrl(actorInfo.avatarUrl)
|
||||
.build();
|
||||
|
||||
notificationRepository.save(notification);
|
||||
log.debug("Created FOLLOW_ACCEPTED notification for user {} from {}", follower.getUsername(), actorInfo.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notifications for a user.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -5,21 +5,28 @@ import lombok.extern.slf4j.Slf4j;
|
|||
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.Follow;
|
||||
import org.operaton.fitpub.model.entity.RemoteActivity;
|
||||
import org.operaton.fitpub.model.entity.RemoteActor;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.FollowRepository;
|
||||
import org.operaton.fitpub.repository.RemoteActivityRepository;
|
||||
import org.operaton.fitpub.repository.RemoteActorRepository;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Service for managing timelines.
|
||||
|
|
@ -33,6 +40,8 @@ public class TimelineService {
|
|||
private final ActivityRepository activityRepository;
|
||||
private final FollowRepository followRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final RemoteActivityRepository remoteActivityRepository;
|
||||
private final RemoteActorRepository remoteActorRepository;
|
||||
private final org.operaton.fitpub.repository.LikeRepository likeRepository;
|
||||
private final org.operaton.fitpub.repository.CommentRepository commentRepository;
|
||||
|
||||
|
|
@ -43,7 +52,8 @@ public class TimelineService {
|
|||
* Get the federated timeline for a user.
|
||||
* Includes public activities from:
|
||||
* - The user's own activities
|
||||
* - Activities from users they follow (local users only for now)
|
||||
* - Activities from local users they follow
|
||||
* - Activities from remote users they follow (federated)
|
||||
*
|
||||
* @param userId the authenticated user's ID
|
||||
* @param pageable pagination parameters
|
||||
|
|
@ -56,44 +66,57 @@ public class TimelineService {
|
|||
User currentUser = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
// Get list of user IDs that the current user follows
|
||||
// 1. Get followed remote actor URIs
|
||||
List<String> remoteActorUris = getFollowedRemoteActorUris(userId);
|
||||
|
||||
// 2. Get followed local user IDs
|
||||
List<UUID> followedUserIds = getFollowedLocalUserIds(userId);
|
||||
followedUserIds.add(userId); // Include the current user's own activities
|
||||
|
||||
// Include the current user's own activities
|
||||
followedUserIds.add(userId);
|
||||
|
||||
// Fetch public and followers-only activities from followed users
|
||||
Page<Activity> activities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||
// 3. Fetch local activities from followed users (fetch more to account for merging)
|
||||
// We fetch double the page size to have enough items after merging
|
||||
Pageable expandedPageable = PageRequest.of(0, pageable.getPageSize() * 2);
|
||||
Page<Activity> localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||
followedUserIds,
|
||||
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
|
||||
pageable
|
||||
expandedPageable
|
||||
);
|
||||
|
||||
// Convert to DTOs
|
||||
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
|
||||
.map(activity -> {
|
||||
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
|
||||
if (activityUser == null) {
|
||||
return null;
|
||||
}
|
||||
TimelineActivityDTO dto = TimelineActivityDTO.fromActivity(
|
||||
activity,
|
||||
activityUser.getUsername(),
|
||||
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
|
||||
activityUser.getAvatarUrl()
|
||||
);
|
||||
// 4. Fetch remote activities from followed remote actors (if any)
|
||||
List<RemoteActivity> remoteActivities = new ArrayList<>();
|
||||
if (!remoteActorUris.isEmpty()) {
|
||||
Page<RemoteActivity> remoteActivitiesPage = remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
|
||||
remoteActorUris,
|
||||
List.of(RemoteActivity.Visibility.PUBLIC, RemoteActivity.Visibility.FOLLOWERS),
|
||||
expandedPageable
|
||||
);
|
||||
remoteActivities = remoteActivitiesPage.getContent();
|
||||
}
|
||||
|
||||
// Add social interaction counts
|
||||
dto.setLikesCount(likeRepository.countByActivityId(activity.getId()));
|
||||
dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId()));
|
||||
dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), userId));
|
||||
// 5. Merge local and remote activities
|
||||
List<TimelineActivityDTO> mergedActivities = mergeActivities(
|
||||
localActivities.getContent(),
|
||||
remoteActivities,
|
||||
userId
|
||||
);
|
||||
|
||||
return dto;
|
||||
})
|
||||
.filter(dto -> dto != null)
|
||||
.collect(Collectors.toList());
|
||||
// 6. Sort chronologically (most recent first) and paginate
|
||||
mergedActivities.sort((a, b) -> {
|
||||
if (a.getStartedAt() == null && b.getStartedAt() == null) return 0;
|
||||
if (a.getStartedAt() == null) return 1;
|
||||
if (b.getStartedAt() == null) return -1;
|
||||
return b.getStartedAt().compareTo(a.getStartedAt());
|
||||
});
|
||||
|
||||
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
|
||||
// Apply pagination to the merged list
|
||||
int start = (int) pageable.getOffset();
|
||||
int end = Math.min(start + pageable.getPageSize(), mergedActivities.size());
|
||||
List<TimelineActivityDTO> paginatedActivities = mergedActivities.subList(
|
||||
Math.min(start, mergedActivities.size()),
|
||||
end
|
||||
);
|
||||
|
||||
return new PageImpl<>(paginatedActivities, pageable, mergedActivities.size());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -205,4 +228,84 @@ public class TimelineService {
|
|||
|
||||
return followedUserIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actor URIs of remote users that the given user follows.
|
||||
*
|
||||
* @param userId the user's ID
|
||||
* @return list of followed remote actor URIs
|
||||
*/
|
||||
private List<String> getFollowedRemoteActorUris(UUID userId) {
|
||||
List<Follow> follows = followRepository.findAcceptedFollowingByUserId(userId);
|
||||
List<String> remoteActorUris = new ArrayList<>();
|
||||
|
||||
for (Follow follow : follows) {
|
||||
// Check if the followed actor is a remote user (not on this instance)
|
||||
String actorUri = follow.getFollowingActorUri();
|
||||
if (!actorUri.startsWith(baseUrl + "/users/")) {
|
||||
remoteActorUris.add(actorUri);
|
||||
}
|
||||
}
|
||||
|
||||
return remoteActorUris;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge local and remote activities into a single list of timeline DTOs.
|
||||
*
|
||||
* @param localActivities list of local Activity entities
|
||||
* @param remoteActivities list of remote RemoteActivity entities
|
||||
* @param currentUserId the current user's ID (for like status)
|
||||
* @return merged list of TimelineActivityDTOs
|
||||
*/
|
||||
private List<TimelineActivityDTO> mergeActivities(
|
||||
List<Activity> localActivities,
|
||||
List<RemoteActivity> remoteActivities,
|
||||
UUID currentUserId
|
||||
) {
|
||||
List<TimelineActivityDTO> merged = new ArrayList<>();
|
||||
|
||||
// Convert local activities to DTOs
|
||||
for (Activity activity : localActivities) {
|
||||
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
|
||||
if (activityUser == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
TimelineActivityDTO dto = TimelineActivityDTO.fromActivity(
|
||||
activity,
|
||||
activityUser.getUsername(),
|
||||
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
|
||||
activityUser.getAvatarUrl()
|
||||
);
|
||||
|
||||
// Add social interaction counts
|
||||
dto.setLikesCount(likeRepository.countByActivityId(activity.getId()));
|
||||
dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId()));
|
||||
dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), currentUserId));
|
||||
|
||||
merged.add(dto);
|
||||
}
|
||||
|
||||
// Convert remote activities to DTOs
|
||||
for (RemoteActivity remoteActivity : remoteActivities) {
|
||||
RemoteActor actor = remoteActorRepository.findByActorUri(remoteActivity.getRemoteActorUri()).orElse(null);
|
||||
if (actor == null) {
|
||||
log.warn("Remote actor not found for URI: {}", remoteActivity.getRemoteActorUri());
|
||||
continue;
|
||||
}
|
||||
|
||||
TimelineActivityDTO dto = TimelineActivityDTO.fromRemoteActivity(remoteActivity, actor);
|
||||
|
||||
// Remote activities don't have like/comment counts in this implementation
|
||||
// (would require additional federation support)
|
||||
dto.setLikesCount(0L);
|
||||
dto.setCommentsCount(0L);
|
||||
dto.setLikedByCurrentUser(false);
|
||||
|
||||
merged.add(dto);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
249
src/main/java/org/operaton/fitpub/service/WebFingerClient.java
Normal file
249
src/main/java/org/operaton/fitpub/service/WebFingerClient.java
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* WebFinger client for discovering ActivityPub actors on remote instances.
|
||||
* Implements RFC 7033 WebFinger protocol with SSRF protection.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WebFingerClient {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${fitpub.domain}")
|
||||
private String localDomain;
|
||||
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
private static final String WEBFINGER_PATH = "/.well-known/webfinger";
|
||||
private static final String ACTIVITYPUB_CONTENT_TYPE = "application/activity+json";
|
||||
|
||||
/**
|
||||
* Discovers an ActivityPub actor URI from a handle.
|
||||
*
|
||||
* @param handle the handle in format @username@domain or username@domain
|
||||
* @return the actor URI (e.g., https://domain.com/users/username)
|
||||
* @throws IllegalArgumentException if handle is invalid or domain is not allowed
|
||||
* @throws IOException if WebFinger request fails
|
||||
*/
|
||||
public String discoverActor(String handle) throws IOException {
|
||||
log.debug("Discovering actor for handle: {}", handle);
|
||||
|
||||
// Parse and validate handle
|
||||
ParsedHandle parsed = parseHandle(handle);
|
||||
String username = parsed.username;
|
||||
String domain = parsed.domain;
|
||||
|
||||
// SSRF protection: validate domain
|
||||
validateDomain(domain);
|
||||
|
||||
// Fetch WebFinger resource
|
||||
Map<String, Object> webFingerResponse = fetchWebFingerResource(domain, username);
|
||||
|
||||
// Extract actor URI from links
|
||||
String actorUri = extractActorUri(webFingerResponse);
|
||||
if (actorUri == null) {
|
||||
throw new IOException("No ActivityPub actor link found in WebFinger response");
|
||||
}
|
||||
|
||||
log.info("Discovered actor URI: {} for handle: {}", actorUri, handle);
|
||||
return actorUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a handle into username and domain components.
|
||||
*
|
||||
* @param handle the handle (e.g., @username@domain or username@domain)
|
||||
* @return parsed handle components
|
||||
* @throws IllegalArgumentException if handle format is invalid
|
||||
*/
|
||||
private ParsedHandle parseHandle(String handle) {
|
||||
if (handle == null || handle.isBlank()) {
|
||||
throw new IllegalArgumentException("Handle cannot be null or empty");
|
||||
}
|
||||
|
||||
// Remove leading @ if present
|
||||
String normalized = handle.startsWith("@") ? handle.substring(1) : handle;
|
||||
|
||||
// Split on @
|
||||
String[] parts = normalized.split("@");
|
||||
if (parts.length != 2) {
|
||||
throw new IllegalArgumentException("Invalid handle format. Expected: @username@domain or username@domain");
|
||||
}
|
||||
|
||||
String username = parts[0].trim();
|
||||
String domain = parts[1].trim();
|
||||
|
||||
if (username.isEmpty() || domain.isEmpty()) {
|
||||
throw new IllegalArgumentException("Username and domain cannot be empty");
|
||||
}
|
||||
|
||||
// Validate username format (alphanumeric, underscore, hyphen)
|
||||
if (!username.matches("^[a-zA-Z0-9_-]+$")) {
|
||||
throw new IllegalArgumentException("Invalid username format. Allowed characters: a-z, A-Z, 0-9, _, -");
|
||||
}
|
||||
|
||||
// Validate domain format (basic check - allow domains and IP addresses)
|
||||
// Domain: must have at least one dot and end with 2+ letters
|
||||
// IP: must be 4 numbers separated by dots
|
||||
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
|
||||
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
|
||||
|
||||
if (!isValidDomain && !isValidIP) {
|
||||
throw new IllegalArgumentException("Invalid domain format");
|
||||
}
|
||||
|
||||
return new ParsedHandle(username, domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches WebFinger resource from a remote domain.
|
||||
*
|
||||
* @param domain the domain to query
|
||||
* @param username the username to look up
|
||||
* @return WebFinger response as a map
|
||||
* @throws IOException if request fails
|
||||
*/
|
||||
private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException {
|
||||
// Construct WebFinger URL
|
||||
String resource = "acct:" + username + "@" + domain;
|
||||
String webFingerUrl = "https://" + domain + WEBFINGER_PATH + "?resource=" + resource;
|
||||
|
||||
log.debug("Fetching WebFinger resource: {}", webFingerUrl);
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(webFingerUrl))
|
||||
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.header("Accept", "application/jrd+json, application/json")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("WebFinger request failed with status: " + response.statusCode());
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> webFingerData = objectMapper.readValue(response.body(), Map.class);
|
||||
|
||||
return webFingerData;
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("WebFinger request interrupted", e);
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Failed to fetch WebFinger resource: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a domain is not a private or loopback address (SSRF protection).
|
||||
*
|
||||
* @param domain the domain to validate
|
||||
* @throws IllegalArgumentException if domain resolves to a private IP
|
||||
*/
|
||||
private void validateDomain(String domain) {
|
||||
// Don't allow requests to local domain (should use local API instead)
|
||||
if (domain.equalsIgnoreCase(localDomain)) {
|
||||
throw new IllegalArgumentException("Cannot discover local users via WebFinger. Use local API instead.");
|
||||
}
|
||||
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(domain);
|
||||
|
||||
// Block loopback addresses (127.0.0.0/8, ::1)
|
||||
if (address.isLoopbackAddress()) {
|
||||
throw new IllegalArgumentException("Loopback addresses are not allowed: " + domain);
|
||||
}
|
||||
|
||||
// Block site-local addresses (private IPs: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
if (address.isSiteLocalAddress()) {
|
||||
throw new IllegalArgumentException("Private IP addresses are not allowed: " + domain);
|
||||
}
|
||||
|
||||
// Block link-local addresses (169.254.0.0/16, fe80::/10)
|
||||
if (address.isLinkLocalAddress()) {
|
||||
throw new IllegalArgumentException("Link-local addresses are not allowed: " + domain);
|
||||
}
|
||||
|
||||
// Block multicast addresses
|
||||
if (address.isMulticastAddress()) {
|
||||
throw new IllegalArgumentException("Multicast addresses are not allowed: " + domain);
|
||||
}
|
||||
|
||||
log.debug("Domain validation passed for: {} (resolved to {})", domain, address.getHostAddress());
|
||||
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalArgumentException("Unable to resolve domain: " + domain, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the ActivityPub actor URI from a WebFinger response.
|
||||
*
|
||||
* @param webFingerResponse the WebFinger response
|
||||
* @return the actor URI, or null if not found
|
||||
*/
|
||||
private String extractActorUri(Map<String, Object> webFingerResponse) {
|
||||
Object linksObj = webFingerResponse.get("links");
|
||||
if (!(linksObj instanceof List)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> links = (List<Map<String, Object>>) linksObj;
|
||||
|
||||
// Look for link with rel="self" and type="application/activity+json"
|
||||
for (Map<String, Object> link : links) {
|
||||
String rel = (String) link.get("rel");
|
||||
String type = (String) link.get("type");
|
||||
String href = (String) link.get("href");
|
||||
|
||||
if ("self".equals(rel) &&
|
||||
(ACTIVITYPUB_CONTENT_TYPE.equals(type) ||
|
||||
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"".equals(type))) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal class to hold parsed handle components.
|
||||
*/
|
||||
private static class ParsedHandle {
|
||||
final String username;
|
||||
final String domain;
|
||||
|
||||
ParsedHandle(String username, String domain) {
|
||||
this.username = username;
|
||||
this.domain = domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
-- Create remote_activities table for storing metadata from federated fitness activities
|
||||
-- IMPORTANT: This table stores METADATA ONLY - no full track data
|
||||
-- Maps and tracks are referenced via URLs pointing to the origin server
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- ActivityPub identifiers
|
||||
activity_uri VARCHAR(512) NOT NULL UNIQUE,
|
||||
remote_actor_uri VARCHAR(512) NOT NULL,
|
||||
|
||||
-- Activity metadata
|
||||
activity_type VARCHAR(50),
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
published_at TIMESTAMP NOT NULL,
|
||||
|
||||
-- Fitness metrics
|
||||
total_distance BIGINT, -- meters
|
||||
total_duration_seconds BIGINT, -- seconds
|
||||
elevation_gain INTEGER, -- meters
|
||||
average_pace_seconds BIGINT, -- seconds per km
|
||||
average_heart_rate INTEGER, -- BPM
|
||||
max_speed DOUBLE PRECISION, -- km/h
|
||||
average_speed DOUBLE PRECISION, -- km/h
|
||||
calories INTEGER,
|
||||
|
||||
-- Remote URLs (point to origin server)
|
||||
map_image_url VARCHAR(512), -- URL to static map image
|
||||
track_geojson_url VARCHAR(512), -- URL to GeoJSON track data (optional)
|
||||
|
||||
-- Visibility
|
||||
visibility VARCHAR(20) NOT NULL, -- PUBLIC, FOLLOWERS, PRIVATE
|
||||
|
||||
-- Full ActivityPub object as JSONB
|
||||
activitypub_object JSONB,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Foreign key constraint
|
||||
CONSTRAINT fk_remote_actor FOREIGN KEY (remote_actor_uri)
|
||||
REFERENCES remote_actors(actor_uri) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE UNIQUE INDEX idx_remote_activity_uri ON remote_activities(activity_uri);
|
||||
CREATE INDEX idx_remote_activity_actor ON remote_activities(remote_actor_uri);
|
||||
CREATE INDEX idx_remote_activity_published ON remote_activities(published_at DESC);
|
||||
CREATE INDEX idx_remote_activity_visibility ON remote_activities(visibility);
|
||||
CREATE INDEX idx_remote_activity_type ON remote_activities(activity_type);
|
||||
|
||||
-- Index for JSONB queries (if needed in the future)
|
||||
CREATE INDEX idx_remote_activity_jsonb ON remote_activities USING gin(activitypub_object);
|
||||
|
||||
-- Comment on table
|
||||
COMMENT ON TABLE remote_activities IS 'Stores metadata-only for remote fitness activities from federated instances';
|
||||
COMMENT ON COLUMN remote_activities.activity_uri IS 'Globally unique ActivityPub activity URI';
|
||||
COMMENT ON COLUMN remote_activities.remote_actor_uri IS 'ActivityPub actor URI of the activity creator';
|
||||
COMMENT ON COLUMN remote_activities.map_image_url IS 'URL to map image on origin server (no local storage)';
|
||||
COMMENT ON COLUMN remote_activities.track_geojson_url IS 'URL to GeoJSON on origin server (optional)';
|
||||
COMMENT ON COLUMN remote_activities.activitypub_object IS 'Full ActivityPub object as JSONB for future re-parsing';
|
||||
|
|
@ -123,7 +123,7 @@ const FitPubTimeline = {
|
|||
<a href="/users/${activity.username}" class="text-decoration-none text-muted">
|
||||
@${this.escapeHtml(activity.username)}
|
||||
</a>
|
||||
${!activity.isLocal ? ' <i class="bi bi-globe2" title="Federated user"></i>' : ''}
|
||||
${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''}
|
||||
• ${this.formatTimeAgo(activity.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -136,9 +136,15 @@ const FitPubTimeline = {
|
|||
|
||||
<!-- Activity Title and Description -->
|
||||
<h5 class="card-title">
|
||||
<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
|
||||
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
||||
</a>
|
||||
${activity.isLocal
|
||||
? `<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
|
||||
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
||||
</a>`
|
||||
: `<a href="${activity.activityUri || '#'}" target="_blank" class="text-decoration-none text-dark">
|
||||
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
||||
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
|
||||
</a>`
|
||||
}
|
||||
</h5>
|
||||
|
||||
${activity.description
|
||||
|
|
@ -171,9 +177,14 @@ const FitPubTimeline = {
|
|||
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
|
||||
<span class="like-count">${activity.likesCount || 0}</span>
|
||||
</button>
|
||||
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View Details
|
||||
</a>
|
||||
${activity.isLocal
|
||||
? `<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View Details
|
||||
</a>`
|
||||
: `<a href="${activity.activityUri || '#'}" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-box-arrow-up-right"></i> View on Origin Server
|
||||
</a>`
|
||||
}
|
||||
<span class="ms-auto text-muted small d-flex align-items-center gap-2">
|
||||
${activity.commentsCount > 0 ? `<span><i class="bi bi-chat-left-text"></i> ${activity.commentsCount}</span>` : ''}
|
||||
<span>
|
||||
|
|
@ -284,6 +295,30 @@ const FitPubTimeline = {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle remote activities differently - show static map image
|
||||
if (!activity.isLocal) {
|
||||
if (activity.mapImageUrl) {
|
||||
mapElement.innerHTML = `
|
||||
<div class="position-relative w-100 h-100">
|
||||
<img src="${this.escapeHtml(activity.mapImageUrl)}"
|
||||
alt="Activity Map"
|
||||
class="img-fluid w-100 h-100"
|
||||
style="object-fit: cover; border-radius: 8px;"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'d-flex align-items-center justify-content-center h-100 bg-light\\'><p class=\\'text-muted\\'>Map not available</p></div>'">
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-globe2"></i> Remote Map
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
mapElement.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 bg-light"><p class="text-muted">No map available for this remote activity</p></div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle local activities - render interactive Leaflet map
|
||||
try {
|
||||
// Fetch track data
|
||||
const response = await fetch(`/api/activities/${activity.id}/track`);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,69 @@
|
|||
<h2 class="mb-1">
|
||||
<i class="bi bi-people"></i> Discover Users
|
||||
</h2>
|
||||
<p class="text-muted">Find and connect with athletes on FitPub</p>
|
||||
<p class="text-muted">Find and connect with athletes on FitPub and across the Fediverse</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote User Discovery -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12 col-md-8 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">
|
||||
<i class="bi bi-globe"></i> Follow Remote Users
|
||||
</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Connect with users from other FitPub instances or ActivityPub-compatible platforms like Mastodon
|
||||
</p>
|
||||
<form id="remoteUserSearchForm">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">@</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="remoteUserHandle"
|
||||
placeholder="username@domain.com"
|
||||
pattern="[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Enter a handle like: alice@fitpub.example or bob@mastodon.social
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Remote User Result -->
|
||||
<div id="remoteUserResult" class="mt-3 d-none">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Remote User Error -->
|
||||
<div id="remoteUserError" class="alert alert-danger mt-3 d-none" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span id="remoteUserErrorText"></span>
|
||||
</div>
|
||||
|
||||
<!-- Remote User Loading -->
|
||||
<div id="remoteUserLoading" class="text-center mt-3 d-none">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Searching...</span>
|
||||
</div>
|
||||
<span class="ms-2 text-muted">Discovering remote user...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local User Search -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3">
|
||||
<i class="bi bi-house-door"></i> Local Users
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -104,6 +166,13 @@
|
|||
const pageSize = 12;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Remote user search form handler
|
||||
const remoteUserSearchForm = document.getElementById('remoteUserSearchForm');
|
||||
remoteUserSearchForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
await searchRemoteUser();
|
||||
});
|
||||
|
||||
// Load initial users (browse mode)
|
||||
loadUsers();
|
||||
|
||||
|
|
@ -158,6 +227,140 @@
|
|||
document.getElementById('searchInfo').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function searchRemoteUser() {
|
||||
const handle = document.getElementById('remoteUserHandle').value.trim();
|
||||
|
||||
if (!handle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading, hide result and error
|
||||
document.getElementById('remoteUserLoading').classList.remove('d-none');
|
||||
document.getElementById('remoteUserResult').classList.add('d-none');
|
||||
document.getElementById('remoteUserError').classList.add('d-none');
|
||||
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch(
|
||||
`/api/users/discover-remote?handle=${encodeURIComponent(handle)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
throw new Error('Invalid handle format. Please use format: username@domain.com');
|
||||
} else if (response.status === 404) {
|
||||
throw new Error('User not found. Please check the handle and try again.');
|
||||
} else {
|
||||
throw new Error('Failed to discover remote user. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const actor = await response.json();
|
||||
|
||||
// Hide loading
|
||||
document.getElementById('remoteUserLoading').classList.add('d-none');
|
||||
|
||||
// Display remote user
|
||||
displayRemoteUser(actor);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error discovering remote user:', error);
|
||||
|
||||
// Hide loading
|
||||
document.getElementById('remoteUserLoading').classList.add('d-none');
|
||||
|
||||
// Show error
|
||||
document.getElementById('remoteUserErrorText').textContent = error.message;
|
||||
document.getElementById('remoteUserError').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function displayRemoteUser(actor) {
|
||||
const resultDiv = document.getElementById('remoteUserResult');
|
||||
|
||||
const avatarHtml = actor.avatarUrl
|
||||
? `<img src="${escapeHtml(actor.avatarUrl)}"
|
||||
alt="${escapeHtml(actor.username)}"
|
||||
class="rounded-circle"
|
||||
width="60"
|
||||
height="60"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">`
|
||||
: '';
|
||||
|
||||
const avatarPlaceholder = `
|
||||
<div class="avatar-placeholder ${actor.avatarUrl ? 'd-none' : ''}"
|
||||
style="width: 60px; height: 60px;">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="me-3">
|
||||
${avatarHtml}
|
||||
${avatarPlaceholder}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">${escapeHtml(actor.displayName || actor.username)}</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
@${escapeHtml(actor.handle)}
|
||||
<span class="badge bg-info ms-2">Remote</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${actor.bio
|
||||
? `<p class="card-text small mb-3">${escapeHtml(actor.bio)}</p>`
|
||||
: '<p class="card-text small text-muted mb-3 fst-italic">No bio</p>'
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary" onclick="followRemoteUser('${escapeHtml(actor.handle)}')">
|
||||
<i class="bi bi-person-plus"></i> Follow
|
||||
</button>
|
||||
${actor.actorUri
|
||||
? `<a href="${escapeHtml(actor.actorUri)}"
|
||||
target="_blank"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-box-arrow-up-right"></i> View Profile
|
||||
</a>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function followRemoteUser(handle) {
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch(
|
||||
`/api/users/${encodeURIComponent(handle)}/follow`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to follow user');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success message
|
||||
FitPub.showAlert('success', result.message || `Follow request sent to ${handle}`);
|
||||
|
||||
// Clear the search form
|
||||
document.getElementById('remoteUserHandle').value = '';
|
||||
document.getElementById('remoteUserResult').classList.add('d-none');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error following remote user:', error);
|
||||
FitPub.showAlert('error', 'Failed to follow user. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
// Show loading
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
52
src/test/resources/application-test.yml
Normal file
52
src/test/resources/application-test.yml
Normal 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
|
||||
2
src/test/resources/init-test-db.sql
Normal file
2
src/test/resources/init-test-db.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Initialize PostGIS extension for test database
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
Loading…
Add table
Add a link
Reference in a new issue