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/id/*").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
|
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users
|
.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/*/followers").permitAll() // User followers list
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
|
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check
|
.requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ public class UserController {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final FollowRepository followRepository;
|
private final FollowRepository followRepository;
|
||||||
private final RemoteActorRepository remoteActorRepository;
|
private final RemoteActorRepository remoteActorRepository;
|
||||||
|
private final org.operaton.fitpub.service.WebFingerClient webFingerClient;
|
||||||
|
private final org.operaton.fitpub.service.FederationService federationService;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -198,6 +200,44 @@ public class UserController {
|
||||||
return ResponseEntity.ok(userDTOs);
|
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.
|
* 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
|
* @param userDetails the authenticated user
|
||||||
* @return success response
|
* @return success response
|
||||||
*/
|
*/
|
||||||
|
|
@ -292,6 +332,22 @@ public class UserController {
|
||||||
User currentUser = userRepository.findByUsername(userDetails.getUsername())
|
User currentUser = userRepository.findByUsername(userDetails.getUsername())
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("Current user not found"));
|
.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
|
// Get the user to follow
|
||||||
User userToFollow = userRepository.findByUsername(username)
|
User userToFollow = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + 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.
|
* 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.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
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.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,6 +41,10 @@ public class TimelineActivityDTO {
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
private boolean isLocal;
|
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
|
// Social interaction counts
|
||||||
private Long likesCount;
|
private Long likesCount;
|
||||||
private Long commentsCount;
|
private Long commentsCount;
|
||||||
|
|
@ -71,6 +78,45 @@ public class TimelineActivityDTO {
|
||||||
.build();
|
.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.
|
* 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
|
* @return the follow relationship if it exists
|
||||||
*/
|
*/
|
||||||
Optional<Follow> findByActivityId(String activityId);
|
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.
|
* 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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -35,6 +36,8 @@ public class InboxProcessor {
|
||||||
private final LikeRepository likeRepository;
|
private final LikeRepository likeRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final org.operaton.fitpub.repository.RemoteActivityRepository remoteActivityRepository;
|
||||||
|
private final org.operaton.fitpub.repository.RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -166,16 +169,40 @@ public class InboxProcessor {
|
||||||
private void processAccept(String username, Map<String, Object> activity) {
|
private void processAccept(String username, Map<String, Object> activity) {
|
||||||
try {
|
try {
|
||||||
Object object = activity.get("object");
|
Object object = activity.get("object");
|
||||||
|
String activityId = null;
|
||||||
|
|
||||||
|
// Handle both embedded object (Map) and reference (String)
|
||||||
if (object instanceof Map) {
|
if (object instanceof Map) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> acceptObject = (Map<String, Object>) object;
|
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);
|
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
|
||||||
if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) {
|
if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) {
|
||||||
|
// Update follow status to ACCEPTED
|
||||||
follow.setStatus(Follow.FollowStatus.ACCEPTED);
|
follow.setStatus(Follow.FollowStatus.ACCEPTED);
|
||||||
followRepository.save(follow);
|
followRepository.save(follow);
|
||||||
log.info("Follow request accepted: {}", activityId);
|
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) {
|
} catch (Exception e) {
|
||||||
|
|
@ -206,11 +233,24 @@ public class InboxProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
String inReplyTo = (String) noteObject.get("inReplyTo");
|
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
|
// Extract activity ID from inReplyTo URI
|
||||||
UUID activityId = extractActivityIdFromUri(inReplyTo);
|
UUID activityId = extractActivityIdFromUri(inReplyTo);
|
||||||
if (activityId == null) {
|
if (activityId == null) {
|
||||||
|
|
@ -260,7 +300,82 @@ public class InboxProcessor {
|
||||||
notificationService.createActivityCommentedNotification(localActivity, comment, actor);
|
notificationService.createActivityCommentedNotification(localActivity, comment, actor);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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();
|
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);
|
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.
|
* 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.dto.TimelineActivityDTO;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
import org.operaton.fitpub.model.entity.Follow;
|
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.model.entity.User;
|
||||||
import org.operaton.fitpub.repository.ActivityRepository;
|
import org.operaton.fitpub.repository.ActivityRepository;
|
||||||
import org.operaton.fitpub.repository.FollowRepository;
|
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.operaton.fitpub.repository.UserRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing timelines.
|
* Service for managing timelines.
|
||||||
|
|
@ -33,6 +40,8 @@ public class TimelineService {
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final FollowRepository followRepository;
|
private final FollowRepository followRepository;
|
||||||
private final UserRepository userRepository;
|
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.LikeRepository likeRepository;
|
||||||
private final org.operaton.fitpub.repository.CommentRepository commentRepository;
|
private final org.operaton.fitpub.repository.CommentRepository commentRepository;
|
||||||
|
|
||||||
|
|
@ -43,7 +52,8 @@ public class TimelineService {
|
||||||
* Get the federated timeline for a user.
|
* Get the federated timeline for a user.
|
||||||
* Includes public activities from:
|
* Includes public activities from:
|
||||||
* - The user's own activities
|
* - 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 userId the authenticated user's ID
|
||||||
* @param pageable pagination parameters
|
* @param pageable pagination parameters
|
||||||
|
|
@ -56,44 +66,57 @@ public class TimelineService {
|
||||||
User currentUser = userRepository.findById(userId)
|
User currentUser = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + 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);
|
List<UUID> followedUserIds = getFollowedLocalUserIds(userId);
|
||||||
|
followedUserIds.add(userId); // Include the current user's own activities
|
||||||
|
|
||||||
// Include the current user's own activities
|
// 3. Fetch local activities from followed users (fetch more to account for merging)
|
||||||
followedUserIds.add(userId);
|
// We fetch double the page size to have enough items after merging
|
||||||
|
Pageable expandedPageable = PageRequest.of(0, pageable.getPageSize() * 2);
|
||||||
// Fetch public and followers-only activities from followed users
|
Page<Activity> localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
Page<Activity> activities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
|
||||||
followedUserIds,
|
followedUserIds,
|
||||||
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
|
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
|
||||||
pageable
|
expandedPageable
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to DTOs
|
// 4. Fetch remote activities from followed remote actors (if any)
|
||||||
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
|
List<RemoteActivity> remoteActivities = new ArrayList<>();
|
||||||
.map(activity -> {
|
if (!remoteActorUris.isEmpty()) {
|
||||||
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
|
Page<RemoteActivity> remoteActivitiesPage = remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
|
||||||
if (activityUser == null) {
|
remoteActorUris,
|
||||||
return null;
|
List.of(RemoteActivity.Visibility.PUBLIC, RemoteActivity.Visibility.FOLLOWERS),
|
||||||
}
|
expandedPageable
|
||||||
TimelineActivityDTO dto = TimelineActivityDTO.fromActivity(
|
);
|
||||||
activity,
|
remoteActivities = remoteActivitiesPage.getContent();
|
||||||
activityUser.getUsername(),
|
}
|
||||||
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
|
|
||||||
activityUser.getAvatarUrl()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add social interaction counts
|
// 5. Merge local and remote activities
|
||||||
dto.setLikesCount(likeRepository.countByActivityId(activity.getId()));
|
List<TimelineActivityDTO> mergedActivities = mergeActivities(
|
||||||
dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId()));
|
localActivities.getContent(),
|
||||||
dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), userId));
|
remoteActivities,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
return dto;
|
// 6. Sort chronologically (most recent first) and paginate
|
||||||
})
|
mergedActivities.sort((a, b) -> {
|
||||||
.filter(dto -> dto != null)
|
if (a.getStartedAt() == null && b.getStartedAt() == null) return 0;
|
||||||
.collect(Collectors.toList());
|
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;
|
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">
|
<a href="/users/${activity.username}" class="text-decoration-none text-muted">
|
||||||
@${this.escapeHtml(activity.username)}
|
@${this.escapeHtml(activity.username)}
|
||||||
</a>
|
</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)}
|
• ${this.formatTimeAgo(activity.startedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,9 +136,15 @@ const FitPubTimeline = {
|
||||||
|
|
||||||
<!-- Activity Title and Description -->
|
<!-- Activity Title and Description -->
|
||||||
<h5 class="card-title">
|
<h5 class="card-title">
|
||||||
<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
|
${activity.isLocal
|
||||||
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
? `<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
|
||||||
</a>
|
${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>
|
</h5>
|
||||||
|
|
||||||
${activity.description
|
${activity.description
|
||||||
|
|
@ -171,9 +177,14 @@ const FitPubTimeline = {
|
||||||
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
|
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
|
||||||
<span class="like-count">${activity.likesCount || 0}</span>
|
<span class="like-count">${activity.likesCount || 0}</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
|
${activity.isLocal
|
||||||
<i class="bi bi-eye"></i> View Details
|
? `<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
|
||||||
</a>
|
<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">
|
<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>` : ''}
|
${activity.commentsCount > 0 ? `<span><i class="bi bi-chat-left-text"></i> ${activity.commentsCount}</span>` : ''}
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -284,6 +295,30 @@ const FitPubTimeline = {
|
||||||
return;
|
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 {
|
try {
|
||||||
// Fetch track data
|
// Fetch track data
|
||||||
const response = await fetch(`/api/activities/${activity.id}/track`);
|
const response = await fetch(`/api/activities/${activity.id}/track`);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,69 @@
|
||||||
<h2 class="mb-1">
|
<h2 class="mb-1">
|
||||||
<i class="bi bi-people"></i> Discover Users
|
<i class="bi bi-people"></i> Discover Users
|
||||||
</h2>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -104,6 +166,13 @@
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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)
|
// Load initial users (browse mode)
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|
||||||
|
|
@ -158,6 +227,140 @@
|
||||||
document.getElementById('searchInfo').classList.add('d-none');
|
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() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
// Show loading
|
// 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
|
// Given
|
||||||
int days = 30;
|
int days = 30;
|
||||||
LocalDate startDate = LocalDate.now().minusDays(days - 1);
|
LocalDate startDate = LocalDate.now().minusDays(days - 1);
|
||||||
List<TrainingLoad> expectedLoad = List.of(
|
List<TrainingLoad> existingLoad = List.of(
|
||||||
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
|
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
|
||||||
);
|
);
|
||||||
|
|
||||||
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
|
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
|
||||||
.thenReturn(expectedLoad);
|
.thenReturn(existingLoad);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
List<TrainingLoad> result = trainingLoadService.getRecentTrainingLoad(userId, days);
|
List<TrainingLoad> result = trainingLoadService.getRecentTrainingLoad(userId, days);
|
||||||
|
|
||||||
// Then
|
// 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(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
|
@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