Skip to content

Commit f29d5d0

Browse files
authored
Implement Sending of Remote Posts with Visibility Modes (Private/Public) (#177)
**Description:** This PR introduces functionality for creating and sending remote posts to recipient servers based on the specified visibility modes: 1. **Private:** Post is sent exclusively only to the followers. 2. **Public:** Post is sent to all the followers servers along with the list of followers and visibility metadata, enabling the recipient server to propagate the post to its users accordingly. Note: This PR covers only the sending side (outgoing posts). Handling and propagation of incoming posts by the inbox handler on the receiver side will be implemented separately. **Changes:** - Implemented remote post creation with visibility metadata (private/public). - Posts are dispatched to the recipient server inbox along with the followers list and visibility metadata. - Added logic ensuring private posts are targeted to specific remote recipients only. **Testing:** - Verified outgoing private posts reach the correct recipient server inbox. - Confirmed outgoing public posts include the correct visibility data and followers list.
1 parent f27f1b2 commit f29d5d0

File tree

7 files changed

+121
-42
lines changed

7 files changed

+121
-42
lines changed

server/src/main/java/edu/sjsu/moth/server/activitypub/message/ActivityPubMessage.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
@NoArgsConstructor
1212
public abstract class ActivityPubMessage {
1313
@JsonProperty("@context")
14-
private final String context = "https://www.w3.org/ns/activitystreams";
14+
private String context = "https://www.w3.org/ns/activitystreams";
1515
private String id;
1616
private String type;
1717
private String actor;

server/src/main/java/edu/sjsu/moth/server/activitypub/message/CreateMessage.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import java.util.List;
77

8+
@Document("outbox")
89
public class CreateMessage extends ActivityPubMessage {
10+
911
public NoteMessage object;
1012
public List<String> to;
1113
public List<String> cc;

server/src/main/java/edu/sjsu/moth/server/activitypub/message/NoteMessage.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import java.util.Map;
1010

1111
@Data
12+
@NoArgsConstructor
13+
@JsonInclude(JsonInclude.Include.NON_NULL)
1214
public class NoteMessage {
1315

1416
/**
@@ -65,12 +67,16 @@ public NoteMessage(String id, String summary, String inReplyTo, String published
6567
}
6668

6769
@Data
70+
@NoArgsConstructor
71+
@JsonInclude(JsonInclude.Include.NON_NULL)
6872
public static class Replies {
6973
private String id;
7074
private String type = "Collection";
7175
private First first;
7276

7377
@Data
78+
@NoArgsConstructor
79+
@JsonInclude(JsonInclude.Include.NON_NULL)
7480
public static class First {
7581
private String type = "CollectionPage";
7682
private String next;

server/src/main/java/edu/sjsu/moth/server/activitypub/service/OutboxService.java

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import edu.sjsu.moth.server.activitypub.message.CreateMessage;
1010
import edu.sjsu.moth.server.activitypub.message.NoteMessage;
1111
import edu.sjsu.moth.server.db.AccountRepository;
12-
import edu.sjsu.moth.server.db.Outbox;
1312
import edu.sjsu.moth.server.db.OutboxRepository;
1413
import lombok.extern.apachecommons.CommonsLog;
1514
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,44 +38,44 @@ public class OutboxService {
3938
AccountRepository accountRepository;
4039

4140
private final ObjectMapper objectMapper;
42-
41+
4342
@Autowired
4443
private OutboxRepository outboxRepository;
4544

4645
public OutboxService() {
4746
this.objectMapper = new ObjectMapper();
4847
}
4948

50-
public Outbox buildCreateActivity(Status status) {
49+
public CreateMessage buildCreateActivity(Status status) {
5150
String actorUrl = ActivityPubUtil.toActivityPubUserUrl(status.account.url);
5251
NoteMessage note = buildNoteMessage(status, actorUrl);
53-
CreateMessage create = new CreateMessage(actorUrl, note);
54-
55-
// 5. Construct and return the persistence object
56-
return new Outbox(create.getId(), // id of the Create activity
57-
create.getActor(), // the actor URL
58-
note.getPublished(), // timestamp
59-
create.to, // to array
60-
create.cc, // cc array
61-
note // full CreateMessage JSON
62-
);
52+
53+
return new CreateMessage(actorUrl, note);
6354
}
6455

6556
private NoteMessage buildNoteMessage(Status status, String actorUrl) {
6657
NoteMessage.Replies.First first = new NoteMessage.Replies.First();
6758
first.setNext(status.getUri() + "/replies?only_other_accounts=true&page=true");
6859
first.setPartOf(status.getUri() + "/replies");
6960
first.setItems(Collections.emptyList());
61+
String cc = "";
62+
String bcc = "";
63+
64+
if (status.visibility != null && status.visibility.equals("private")) {
65+
cc = actorUrl + "/followers";
66+
bcc = "";
67+
} else {
68+
cc = "https://www.w3.org/ns/activitystreams#Public";
69+
bcc = actorUrl + "/followers";
70+
}
7071

7172
NoteMessage.Replies replies = new NoteMessage.Replies();
7273
replies.setId(status.getUri() + "/replies");
7374
replies.setFirst(first);
7475

75-
return new NoteMessage(status.getUri(), null, null, status.createdAt, status.getUrl(), actorUrl,
76-
List.of("https://www.w3.org/ns/activitystreams#Public"),
77-
List.of(actorUrl + "/followers"), status.sensitive, status.getUri(), null, status.text,
78-
status.content, Map.of("en", status.content), Collections.emptyList(),
79-
Collections.emptyList(), replies);
76+
return new NoteMessage(status.getUri(), null, null, status.createdAt, status.getUrl(), actorUrl, List.of(cc),
77+
List.of(bcc), status.sensitive, status.getUri(), null, status.text, status.content,
78+
Map.of("en", status.content), Collections.emptyList(), Collections.emptyList(), replies);
8079
}
8180

8281
public Mono<ResponseEntity<JsonNode>> getOutboxIndex(String username, String baseOutbox) {
@@ -106,9 +105,9 @@ public Mono<ResponseEntity<JsonNode>> getOutbox(String username, String actorUrl
106105
final String baseOutbox = actorUrl + "/outbox";
107106
return outboxRepository.findAllByActorOrderByPublishedAtDesc(actorUrl).collectList().flatMap(messages -> {
108107
// cursor‐style pagination
109-
List<Outbox> pageItems = new ArrayList<>();
108+
List<CreateMessage> pageItems = new ArrayList<>();
110109
boolean skipping = (minId != null);
111-
for (Outbox msg : messages) {
110+
for (CreateMessage msg : messages) {
112111
if (skipping) {
113112
if (msg.getId().equals(minId)) skipping = false;
114113
continue;
@@ -136,7 +135,7 @@ public Mono<ResponseEntity<JsonNode>> getOutbox(String username, String actorUrl
136135
root.put("id", baseOutbox + queryString);
137136
root.put("type", "OrderedCollectionPage");
138137
if (!pageItems.isEmpty()) {
139-
String statusUrl = pageItems.get(0).getObject().getId();
138+
String statusUrl = pageItems.get(0).object.getId();
140139
String statusId = statusUrl.substring(statusUrl.lastIndexOf('/') + 1);
141140
String prevQs =
142141
"?min_id=" + URLEncoder.encode(statusId, StandardCharsets.UTF_8) + "&limit=" + pageSize +
@@ -147,7 +146,7 @@ public Mono<ResponseEntity<JsonNode>> getOutbox(String username, String actorUrl
147146

148147
// orderedItems
149148
ArrayNode items = root.putArray("orderedItems");
150-
for (Outbox msg : pageItems) {
149+
for (CreateMessage msg : pageItems) {
151150
items.add(objectMapper.valueToTree(msg));
152151
}
153152
return Mono.just(ResponseEntity.ok().contentType(MediaType.parseMediaType("application/activity+json"))
@@ -157,5 +156,4 @@ public Mono<ResponseEntity<JsonNode>> getOutbox(String username, String actorUrl
157156
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
158157
});
159158
}
160-
161159
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
import reactor.core.publisher.Flux;
77
import reactor.core.publisher.Mono;
88

9-
public interface OutboxRepository extends ReactiveMongoRepository<Outbox, String> {
9+
public interface OutboxRepository extends ReactiveMongoRepository<CreateMessage, String> {
1010

1111
@Query("{ 'actor': ?0 }")
12-
Flux<Outbox> findAllByActorOrderByPublishedAtDesc(String actor);
12+
Flux<CreateMessage> findAllByActorOrderByPublishedAtDesc(String actor);
1313

1414
@Query(value = "{ 'actor': ?0 }", count = true)
1515
Mono<Long> countAllByActor(String actor);

server/src/main/java/edu/sjsu/moth/server/service/StatusService.java

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
package edu.sjsu.moth.server.service;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.node.ArrayNode;
6+
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
38
import com.querydsl.core.types.Ops;
49
import com.querydsl.core.types.Path;
510
import com.querydsl.core.types.dsl.BooleanExpression;
611
import com.querydsl.core.types.dsl.Expressions;
7-
import edu.sjsu.moth.generated.QStatus;
812
import edu.sjsu.moth.generated.SearchResult;
913
import edu.sjsu.moth.generated.Status;
1014
import edu.sjsu.moth.generated.StatusEdit;
1115
import edu.sjsu.moth.generated.StatusSource;
16+
import edu.sjsu.moth.server.activitypub.ActivityPubUtil;
17+
import edu.sjsu.moth.server.activitypub.message.CreateMessage;
18+
import edu.sjsu.moth.server.activitypub.service.OutboxService;
1219
import edu.sjsu.moth.server.activitypub.service.OutboxService;
1320
import edu.sjsu.moth.server.db.AccountField;
1421
import edu.sjsu.moth.server.db.AccountRepository;
@@ -21,6 +28,8 @@
2128
import edu.sjsu.moth.server.db.StatusHistoryRepository;
2229
import edu.sjsu.moth.server.db.StatusMention;
2330
import edu.sjsu.moth.server.db.StatusRepository;
31+
import edu.sjsu.moth.generated.QStatus;
32+
import edu.sjsu.moth.server.util.MothConfiguration;
2433
import lombok.extern.apachecommons.CommonsLog;
2534
import org.bson.types.ObjectId;
2635
import org.jetbrains.annotations.NotNull;
@@ -30,11 +39,17 @@
3039
import org.springframework.security.core.userdetails.UsernameNotFoundException;
3140
import reactor.core.publisher.Flux;
3241
import reactor.core.publisher.Mono;
42+
import reactor.core.scheduler.Schedulers;
3343

44+
import java.net.MalformedURLException;
45+
import java.net.URI;
46+
import java.net.URL;
3447
import java.security.Principal;
3548
import java.util.ArrayList;
3649
import java.util.List;
3750

51+
import static edu.sjsu.moth.server.util.Util.signAndSend;
52+
3853
@Configuration
3954
@CommonsLog
4055
public class StatusService {
@@ -55,17 +70,19 @@ public class StatusService {
5570
AccountService accountService;
5671

5772
@Autowired
58-
StatusHistoryRepository statusHistoryRepository;
73+
VisibilityService visibilityService;
5974

6075
@Autowired
61-
VisibilityService visibilityService;
76+
StatusHistoryRepository statusHistoryRepository;
6277

6378
@Autowired
6479
OutboxService outboxService;
6580

6681
@Autowired
6782
OutboxRepository outboxRepository;
6883

84+
private final ObjectMapper objectMapper = new ObjectMapper();
85+
6986
public Mono<ArrayList<StatusEdit>> findHistory(String id) {
7087
return statusHistoryRepository.findById(id).map(edits -> edits.collection);
7188
}
@@ -150,11 +167,20 @@ public Mono<Status> save(Status status) {
150167
}));
151168
}
152169

153-
return mono.then(statusRepository.save(status)).flatMap(
154-
savedStatus -> outboxRepository.save(outboxService.buildCreateActivity(savedStatus))
155-
.thenReturn(savedStatus)).flatMap(
156-
s -> statusHistoryRepository.findById(s.id).defaultIfEmpty(new StatusEditCollection(s.id))
157-
.flatMap(sh -> statusHistoryRepository.save(sh.addEdit(s))).thenReturn(s));
170+
return mono.then(statusRepository.save(status)).flatMap(savedStatus -> {
171+
CreateMessage createMessage = outboxService.buildCreateActivity(savedStatus);
172+
Flux<Void> fanOut = outboxRepository.save(createMessage)
173+
.thenMany(getRemoteFollowerInboxes(savedStatus.account.id).flatMapMany(Flux::fromIterable))
174+
.flatMap(inboxUrl -> sendCreate(createMessage, inboxUrl));
175+
176+
// schedule it on boundedElastic, Invoke and forget
177+
fanOut.subscribeOn(Schedulers.boundedElastic()).subscribe();
178+
179+
return Mono.just(savedStatus); // immediately return savedStatus
180+
}).flatMap(savedStatus -> statusHistoryRepository.findById(savedStatus.id)
181+
.defaultIfEmpty(new StatusEditCollection(savedStatus.id))
182+
.flatMap(sh -> statusHistoryRepository.save(sh.addEdit(savedStatus))).thenReturn(savedStatus));
183+
158184
}
159185

160186
public Mono<ExternalStatus> saveExternal(ExternalStatus status) {
@@ -274,4 +300,51 @@ private String convertToHex(String payload) {
274300
return String.format("%1$24s", payload).replace(' ', '0');
275301
}
276302

303+
private Mono<List<String>> getRemoteFollowerInboxes(String accountId) {
304+
String localDomain = MothConfiguration.mothConfiguration.getServerName();
305+
306+
return followRepository.findAllByFollowedId(accountId) // Flux<Follow>
307+
// get the raw “acct” handle
308+
.map(f -> f.id.follower_id) // Mono<String> of "username@domain"
309+
// extract the domain part
310+
.map(handle -> {
311+
int at = handle.indexOf('@');
312+
if (at < 0 || at == handle.length() - 1) {
313+
// invalid handle, drop it
314+
return null;
315+
}
316+
return handle.substring(at + 1);
317+
})
318+
// drop any nulls or locals
319+
.filter(domain -> domain != null && !domain.equals(localDomain))
320+
// build the inbox URL
321+
.map(domain -> "https://" + domain + "/inbox")
322+
// avoid duplicates
323+
.distinct()
324+
// collect into a List<String>
325+
.collectList();
326+
}
327+
328+
public Mono<Void> sendCreate(CreateMessage create, String inboxUrl) {
329+
// 1) build the JSON tree for signing & sending
330+
ObjectNode createJson = objectMapper.valueToTree(create);
331+
332+
// 2) extract the local actor name from the actor URL
333+
String actorUrl = create.getActor();
334+
String actorName = URI.create(actorUrl).getPath() // "/users/alice"
335+
.substring("/users/".length());
336+
337+
// 3) fetch the actor’s private key, then sign & send
338+
return accountService.getPrivateKey(actorName, /* localOnly= */ true).flatMap(privKey ->
339+
// signAndSend is your
340+
// existing method that
341+
// does HTTP Signature
342+
// + POST
343+
signAndSend(createJson,
344+
actorUrl,
345+
inboxUrl,
346+
privKey));
347+
}
348+
277349
}
350+

server/src/test/java/edu/sjsu/moth/controllers/StatusControllerTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,19 @@
1212
import edu.sjsu.moth.IntegrationTest;
1313
import edu.sjsu.moth.generated.Status;
1414
import edu.sjsu.moth.server.MothServerMain;
15+
import edu.sjsu.moth.server.activitypub.message.CreateMessage;
1516
import edu.sjsu.moth.server.activitypub.message.NoteMessage;
1617
import edu.sjsu.moth.server.controller.StatusController;
1718
import edu.sjsu.moth.server.db.Account;
1819
import edu.sjsu.moth.server.db.AccountRepository;
1920
import edu.sjsu.moth.server.db.Follow;
2021
import edu.sjsu.moth.server.db.FollowRepository;
21-
import edu.sjsu.moth.server.db.Outbox;
2222
import edu.sjsu.moth.server.db.OutboxRepository;
2323
import edu.sjsu.moth.server.db.StatusRepository;
2424
import edu.sjsu.moth.server.db.TokenRepository;
2525
import edu.sjsu.moth.server.service.StatusService;
2626
import edu.sjsu.moth.server.util.MothConfiguration;
2727
import org.junit.jupiter.api.AfterAll;
28-
import org.junit.jupiter.api.Assertions;
2928
import org.junit.jupiter.api.BeforeAll;
3029
import org.junit.jupiter.api.Test;
3130
import org.springframework.beans.factory.annotation.Autowired;
@@ -266,21 +265,22 @@ public void testOutboxDataSave() {
266265
}
267266

268267
// 3) build the actor URL
269-
String actorUrl = "https://" + MothConfiguration.mothConfiguration.getServerName() + "/users/" + statusCreator;
268+
String actor = "https://" + MothConfiguration.mothConfiguration.getServerName() + "/users/" + statusCreator;
270269

271270
// 4) fetch all outbox messages for this actor
272-
List<Outbox> outboxList = outboxRepository.findAllByActorOrderByPublishedAtDesc(actorUrl).collectList().block();
271+
List<CreateMessage> outboxList =
272+
outboxRepository.findAllByActorOrderByPublishedAtDesc(actor).collectList().block();
273+
273274

274275
// 5) verify that we stored exactly one Create per post
275276
assertNotNull(outboxList, "Outbox list should not be null");
276277
assertEquals(visibilities.length, outboxList.size(), "Expected one outbox entry per status posted");
277278

278279
// 6) spot‐check the contents of each stored activity
279-
for (Outbox out : outboxList) {
280-
NoteMessage activity = out.getObject();
280+
for (CreateMessage out : outboxList) {
281+
NoteMessage activity = out.object;
281282
assertEquals("Create", out.getType(), "Activity type must be Create");
282-
assertEquals(actorUrl, out.getActor(), "Actor URL must match");
283-
283+
assertEquals(actor, out.getActor(), "Actor URL must match");
284284
assertEquals("Note", activity.getType(), "Object type must be Note");
285285
assertTrue(activity.getContent().contains("This is a"), "Note content missing");
286286
}

0 commit comments

Comments
 (0)