Skip to content

Commit 0adb45f

Browse files
authored
Vamshi/centralize activity pub messages (#172)
**Summary** This PR extends our new centralized ActivityPub message layer to fully support both incoming and outgoing Follow/Unfollow workflows, with correct state transitions for local vs. remote users, auto-accept logic, and count updates. It also introduces a set of message classes for JSON-LD formatting. --- **New Message Layer** * Extracted common ActivityPub logic into a `MessageService` to centralize serialization. This lays the groundwork for handling other ActivityPub message types (Like, Create, Announce, etc.) using the same centralized layer. * Created a base `ActivityPubMessage` plus concrete `FollowMessage`, `AcceptMessage`, and `UndoMessage` classes to emit JSON-LD–compliant ActivityPub payloads. --- **Follow Handling** * **Incoming** * Receive and validate incoming `Follow` activities. * **Local users**: auto-accept the follow, persist the relation, increment follower/following counts. * **Remote users**: persist the relation, then send an `AcceptMessage` back. * **Outgoing** * On initiating a follow, build and send a `FollowMessage`. * **Remote targets**: transition to **FOLLOWING** only after receiving an `Accept` response from the remote server. * **Local targets**: skip the request step and go straight to **FOLLOWING**. --- **Unfollow Handling** * **Incoming** * Receive and validate incoming `Undo` (`Unfollow`) activities. * Decrement follower/following counts and remove the follow relation. * (Per spec, no Accept is sent for Undo.) * **Outgoing** * Build and send an `UndoMessage` when the user unfollows. * **Remote targets**: remove the relation once the message is sent. * **Local targets**: remove immediately. --- **State Management & Counts** * Use `updateFollowersCount` and `updateFollowingCount` helpers to recalculate and persist both users’ `followers_count` and `following_count` after any follow/unfollow event. --- **Impact & Next Steps** * Lays the foundation for additional ActivityPub types (Like, Create, Announce, etc.) using the same `MessageService`. * Future work will Introduce a `Status` enum (`REQUESTED`, `ACCEPTED`) on the `Follow` entity.
1 parent 99d65df commit 0adb45f

File tree

12 files changed

+594
-164
lines changed

12 files changed

+594
-164
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package edu.sjsu.moth.server.activitypub;
2+
3+
import edu.sjsu.moth.server.util.MothConfiguration;
4+
5+
import java.net.MalformedURLException;
6+
import java.net.URI;
7+
import java.net.URL;
8+
9+
public class ActivityPubUtil {
10+
11+
public static String inboxUrlToAcct(String inboxUrl) {
12+
URI uri = URI.create(inboxUrl);
13+
String host = uri.getHost();
14+
String path = uri.getPath();
15+
String[] segments = path.split("/");
16+
17+
if (segments.length < 2) {
18+
throw new IllegalArgumentException("Invalid inbox URL format: " + inboxUrl);
19+
}
20+
21+
String username = segments[segments.length - 1];
22+
23+
if (!isRemoteUser(inboxUrl)) {
24+
return username;
25+
} else {
26+
return username + "@" + host;
27+
}
28+
}
29+
30+
public static String getActorUrl(String id) {
31+
return String.format("https://%s/users/%s", MothConfiguration.mothConfiguration.getServerName(), id);
32+
}
33+
34+
public static String getRemoteDomain(String uri) {
35+
try {
36+
return new URL(uri).getHost();
37+
} catch (MalformedURLException e) {
38+
return null;
39+
}
40+
}
41+
42+
public static boolean isRemoteUser(String actor) {
43+
String domain = getRemoteDomain(actor);
44+
String localDomain = MothConfiguration.mothConfiguration.getServerName();
45+
46+
if (domain == null) {
47+
return false;
48+
}
49+
50+
return !domain.equals(localDomain);
51+
}
52+
53+
//Just some local implementations until I figure out the actual requirement and middleware
54+
public static String toActivityPubUserUrl(String url) {
55+
if (url == null || !url.contains("/@")) {
56+
throw new IllegalArgumentException("Invalid Mastodon-style URL");
57+
}
58+
59+
String baseUrl = url.substring(0, url.indexOf("/@"));
60+
String username = url.substring(url.indexOf("/@") + 2); // Skip "/@"
61+
return baseUrl + "/users/" + username;
62+
}
63+
64+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package edu.sjsu.moth.server.activitypub.message;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
5+
public class AcceptMessage extends ActivityPubMessage {
6+
//Accept message accepts an Object for the object parameter
7+
public JsonNode object;
8+
9+
public AcceptMessage(String actor, JsonNode object) {
10+
super("Accept", actor);
11+
this.object = object;
12+
}
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package edu.sjsu.moth.server.activitypub.message;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import edu.sjsu.moth.server.util.MothConfiguration;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.util.UUID;
9+
10+
@Data
11+
@NoArgsConstructor
12+
public abstract class ActivityPubMessage {
13+
@JsonProperty("@context")
14+
private final String context = "https://www.w3.org/ns/activitystreams";
15+
private String id;
16+
private String type;
17+
private String actor;
18+
19+
protected ActivityPubMessage(String type, String actor) {
20+
this.type = type;
21+
this.actor = actor;
22+
this.id =
23+
String.format("https://%s/%s", MothConfiguration.mothConfiguration.getServerName(), UUID.randomUUID());
24+
}
25+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package edu.sjsu.moth.server.activitypub.message;
2+
3+
public class FollowMessage extends ActivityPubMessage {
4+
//Follow message accepts a String for the object parameter
5+
public String object;
6+
7+
public FollowMessage(String actor, String object) {
8+
super("Follow", actor);
9+
this.object = object;
10+
}
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package edu.sjsu.moth.server.activitypub.message;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
5+
public class UndoMessage extends ActivityPubMessage {
6+
//Follow message accepts a String for the object parameter
7+
public JsonNode object;
8+
9+
public UndoMessage(JsonNode object) {
10+
super("Undo", object.get("actor").asText());
11+
this.object = object;
12+
}
13+
}

server/src/main/java/edu/sjsu/moth/server/controller/AccountController.java

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@
2626
import org.springframework.web.bind.annotation.RequestHeader;
2727
import org.springframework.web.bind.annotation.RequestParam;
2828
import org.springframework.web.bind.annotation.RestController;
29+
import org.springframework.web.server.ResponseStatusException;
2930
import reactor.core.publisher.Flux;
3031
import reactor.core.publisher.Mono;
3132

3233
import java.security.Principal;
3334
import java.util.ArrayList;
3435
import java.util.Arrays;
3536
import java.util.List;
37+
import java.util.Map;
3638
import java.util.regex.Pattern;
3739
import java.util.stream.Collectors;
3840

@@ -46,7 +48,7 @@ public class AccountController {
4648
@Autowired
4749
private final FollowService followService;
4850

49-
public AccountController(AccountService accountService,FollowService followService) {
51+
public AccountController(AccountService accountService, FollowService followService) {
5052
this.accountService = accountService;
5153
this.followService = followService;
5254
}
@@ -145,7 +147,6 @@ public Mono<ResponseEntity<List<Relationship>>> getApiV1AccountsRelationships(Pr
145147
.collect(Collectors.toList());
146148
return Flux.merge(relationshipMonos).collectList().map(ResponseEntity::ok);
147149
});
148-
149150
}
150151

151152
// spec: https://docs.joinmastodon.org/methods/accounts/#get
@@ -172,18 +173,28 @@ public Mono<ArrayList<Account>> getBlocks(Integer max_id, Integer since_id, Inte
172173

173174
//Follow request sent out to other instances/ other users
174175
@PostMapping("/api/v1/accounts/{id}/follow")
175-
public Mono<ResponseEntity<Relationship>> followUser(@PathVariable("id") String followedId, Principal user) {
176-
177-
return accountService.getAccountById(user.getName()).flatMap(a -> followService.followUser(a.id, followedId))
178-
.map(ResponseEntity::ok);
176+
public Mono<ResponseEntity<Object>> followUser(@PathVariable("id") String followedId, Principal user) {
177+
return followService.followUser(user.getName(), followedId)
178+
.map(rel -> ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body((Object) rel))
179+
.onErrorResume(ResponseStatusException.class, ex -> {
180+
Map<String, String> errorBody =
181+
Map.of("error", ex.getStatusCode().toString(), "message", ex.getReason());
182+
return Mono.just(ResponseEntity.status(ex.getStatusCode()).contentType(MediaType.APPLICATION_JSON)
183+
.body((Object) errorBody));
184+
});
179185
}
180186

181187
//Follow request sent out to other instances/ other users
182188
@PostMapping("/api/v1/accounts/{id}/unfollow")
183-
public Mono<ResponseEntity<Relationship>> unfollowUser(@PathVariable("id") String followedId, Principal user) {
184-
185-
return accountService.getAccountById(user.getName()).flatMap(a -> followService.unfollowUser(a.id, followedId))
186-
.map(ResponseEntity::ok);
189+
public Mono<ResponseEntity<Object>> unfollowUser(@PathVariable("id") String followedId, Principal user) {
190+
return followService.unfollowUser(user.getName(), followedId)
191+
.map(rel -> ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body((Object) rel))
192+
.onErrorResume(ResponseStatusException.class, ex -> {
193+
Map<String, String> errorBody =
194+
Map.of("error", ex.getStatusCode().toString(), "message", ex.getReason());
195+
return Mono.just(ResponseEntity.status(ex.getStatusCode()).contentType(MediaType.APPLICATION_JSON)
196+
.body((Object) errorBody));
197+
});
187198
}
188199

189200
// @GetMapping("/api/v1/accounts/{id}/following")

server/src/main/java/edu/sjsu/moth/server/controller/InboxController.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,21 @@ public Mono<Actor> createActor(String accountLink) {
162162
@PostMapping("/users/{id}/inbox")
163163
public Mono<String> usersInbox(@PathVariable String id, @RequestBody JsonNode inboxNode) {
164164
String requestType = inboxNode.get("type").asText();
165-
// follow or unfollow requests
166-
if (requestType.equals("Follow") || requestType.equals("Undo"))
167-
return accountService.followerHandler(id, inboxNode, requestType);
168-
return Mono.empty();
165+
// follow or unfollow requests from other or same instances
166+
switch (requestType) {
167+
case "Follow" -> {
168+
return accountService.followerHandler(id, inboxNode,false);
169+
}
170+
case "Undo"->{
171+
return accountService.followerHandler(id, inboxNode,true);
172+
}
173+
case "Accept" -> {
174+
return accountService.acceptHandler(id,inboxNode);
175+
}
176+
default -> {
177+
return Mono.empty();
178+
}
179+
}
169180
}
170181

171182
@GetMapping("/users/{id}/following")

server/src/main/java/edu/sjsu/moth/server/db/AcceptMessage.java

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)