From 2a64e080a395170875d62d3bebaf4dce3815bb13 Mon Sep 17 00:00:00 2001
From: Vitaly Takmazov
Date: Sat, 3 Nov 2018 13:14:15 +0300
Subject: ActivityPub: mentions with @user@server.tld
---
.../src/main/java/com/juick/util/MessageUtils.java | 19 +++++++++---
.../src/test/java/com/juick/MessageTest.java | 2 ++
.../java/com/juick/server/ActivityPubManager.java | 9 ++++++
.../java/com/juick/server/SignatureManager.java | 26 ++++++++++++----
.../com/juick/server/api/activity/Profile.java | 18 +++++------
.../juick/server/api/activity/model/Context.java | 3 +-
.../com/juick/server/api/webfinger/Resource.java | 4 ++-
.../java/com/juick/server/tests/ServerTests.java | 23 +++++++-------
juick-server/src/test/resources/webfinger.json | 36 ++++++++++++++++++++++
9 files changed, 107 insertions(+), 33 deletions(-)
create mode 100644 juick-server/src/test/resources/webfinger.json
diff --git a/juick-common/src/main/java/com/juick/util/MessageUtils.java b/juick-common/src/main/java/com/juick/util/MessageUtils.java
index 62616bdf..fd357c32 100644
--- a/juick-common/src/main/java/com/juick/util/MessageUtils.java
+++ b/juick-common/src/main/java/com/juick/util/MessageUtils.java
@@ -71,6 +71,9 @@ public class MessageUtils {
private final static String usernameRegex = "((?<=\\s)|(?<=\\A))@([\\w\\-]{2,16})((?=\\s)|(?=\\Z)|(?=\\p{Punct}))";
private final static Pattern usernamePattern = Pattern.compile(usernameRegex);
+ private final static String jidRegex = "((?<=\\s)|(?<=\\A))@([\\w\\-\\.]+@[\\w\\-\\.]+)((?=\\s)|(?=\\Z)|(?=\\p{Punct}))";
+ private final static Pattern jidPattern = Pattern.compile(jidRegex);
+
public static String formatMessageCode(String msg) {
msg = msg.replaceAll("&", "&");
msg = msg.replaceAll("<", "<");
@@ -138,7 +141,7 @@ public class MessageUtils {
// @username@jabber.org
// @username@jabber.org
- msg = msg.replaceAll("((?<=\\s)|(?<=\\A))@([\\w\\-\\.]+@[\\w\\-\\.]+)((?=\\s)|(?=\\Z)|(?=\\p{Punct}))", "$1@$2$3");
+ msg = msg.replaceAll(jidRegex, "$1@$2$3");
// @username
// @username
@@ -304,12 +307,18 @@ public class MessageUtils {
}
return input;
}
- public static List getMentions(Message msg) {
- Matcher usernameMatcher = usernamePattern.matcher(msg.getText());
+ private static List collectMatches(Pattern pattern, String input) {
+ Matcher matcher = pattern.matcher(input);
List result = new ArrayList<>();
- while (usernameMatcher.find()) {
- result.add(usernameMatcher.group());
+ while (matcher.find()) {
+ result.add(matcher.group());
}
return result;
}
+ public static List getMentions(Message msg) {
+ return collectMatches(usernamePattern, msg.getText());
+ }
+ public static List getGlobalMentions(Message msg) {
+ return collectMatches(jidPattern, msg.getText());
+ }
}
diff --git a/juick-common/src/test/java/com/juick/MessageTest.java b/juick-common/src/test/java/com/juick/MessageTest.java
index d9c83b84..6197f861 100644
--- a/juick-common/src/test/java/com/juick/MessageTest.java
+++ b/juick-common/src/test/java/com/juick/MessageTest.java
@@ -177,5 +177,7 @@ public class MessageTest {
List mentions = MessageUtils.getMentions(msg);
assertThat(mentions.size(), is(1));
assertThat(mentions.get(0), is("@ugnich"));
+ msg.setText("And dick is @ugnich@jabber.zp.ua");
+ assertThat(MessageUtils.getGlobalMentions(msg).size(), is(1));
}
}
diff --git a/juick-server/src/main/java/com/juick/server/ActivityPubManager.java b/juick-server/src/main/java/com/juick/server/ActivityPubManager.java
index 0cb76a03..6f5163cc 100644
--- a/juick-server/src/main/java/com/juick/server/ActivityPubManager.java
+++ b/juick-server/src/main/java/com/juick/server/ActivityPubManager.java
@@ -262,6 +262,15 @@ public class ActivityPubManager implements ActivityListener, NotificationListene
} else if (MessageUtils.isReply(msg)) {
note.getTags().add(new Mention(personWebUri(msg.getTo()), msg.getTo().getName()));
}
+ MessageUtils.getGlobalMentions(msg).forEach(m -> {
+ // @user@server.tld -> user@server.tld
+ Optional personContext = signatureManager.discoverPerson(m.substring(1));
+ if (personContext.isPresent()) {
+ Person person = (Person) personContext.get();
+ note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername()));
+ note.getCc().add(person.getUrl());
+ }
+ });
if (msg.isHtml()) {
note.setContent(msg.getText());
} else {
diff --git a/juick-server/src/main/java/com/juick/server/SignatureManager.java b/juick-server/src/main/java/com/juick/server/SignatureManager.java
index 927a62a7..b3b7a301 100644
--- a/juick-server/src/main/java/com/juick/server/SignatureManager.java
+++ b/juick-server/src/main/java/com/juick/server/SignatureManager.java
@@ -3,22 +3,22 @@ package com.juick.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juick.server.api.activity.model.Context;
import com.juick.server.api.activity.model.objects.Person;
-import com.juick.service.activities.DeleteUserEvent;
+import com.juick.server.api.webfinger.model.Account;
+import com.juick.server.api.webfinger.model.Link;
import com.juick.util.DateFormattersHolder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
-import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import org.tomitribe.auth.signatures.Signature;
import org.tomitribe.auth.signatures.Signer;
import org.tomitribe.auth.signatures.Verifier;
+import rocks.xmpp.addr.Jid;
import javax.inject.Inject;
import java.io.IOException;
@@ -27,12 +27,12 @@ import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.security.SignatureException;
import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import static com.juick.server.api.activity.model.Context.ACTIVITY_MEDIA_TYPE;
+
@Component
public class SignatureManager {
private static final Logger logger = LoggerFactory.getLogger(ActivityPubManager.class);
@@ -58,7 +58,7 @@ public class SignatureManager {
headers.put("date", requestDate);
Signature signature = signer.sign("POST", inbox.getPath(), headers);
HttpHeaders requestHeaders = new HttpHeaders();
- requestHeaders.add("Content-Type", Context.ACTIVITY_JSON_MEDIA_TYPE);
+ requestHeaders.add("Content-Type", Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE);
requestHeaders.add("Date", requestDate);
requestHeaders.add("Signature", signature.toString().substring(10));
HttpEntity request = new HttpEntity<>(Context.build(data), requestHeaders);
@@ -96,4 +96,18 @@ public class SignatureManager {
}
return Optional.of(context);
}
+ public Optional discoverPerson(String acct) {
+ Jid acctId = Jid.of(acct);
+ URI resourceUri = UriComponentsBuilder.fromUriString(
+ String.format("https://%s/.well-known/webfinger?resource=acct:%s", acctId.getDomain(), acct)).build().toUri();
+ Account acctData = apClient.getForEntity(resourceUri, Account.class).getBody();
+ if (acctData != null) {
+ for (Link l : acctData.getLinks()) {
+ if (l.getRel().equals("self") && l.getType().equals(ACTIVITY_MEDIA_TYPE)) {
+ return getContext(URI.create(l.getHref()));
+ }
+ }
+ }
+ return Optional.empty();
+ }
}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/Profile.java b/juick-server/src/main/java/com/juick/server/api/activity/Profile.java
index ce09436d..10390ea1 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/Profile.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/Profile.java
@@ -82,7 +82,7 @@ public class Profile {
@Inject
private ObjectMapper jsonMapper;
- @GetMapping(value = "/u/{userName}", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public Person getUser(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -111,7 +111,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/blog/toc", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}/blog/toc", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public OrderedCollection getOutbox(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -125,7 +125,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/blog", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}/blog", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public OrderedCollectionPage getOutboxPage(@PathVariable String userName,
@RequestParam(required = false, defaultValue = "0") int before) {
User visitor = UserUtils.getCurrentUser();
@@ -158,7 +158,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/followers/toc", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}/followers/toc", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public OrderedCollection getFollowers(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -172,7 +172,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/followers", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}/followers", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public OrderedCollectionPage getFollowersPage(@PathVariable String userName,
@RequestParam(required = false, defaultValue = "0") int page) {
User user = userService.getUserByName(userName);
@@ -200,7 +200,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/following/toc", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}/following/toc", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public OrderedCollection getFollowing(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -214,7 +214,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/following", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/u/{userName}/following", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public OrderedCollectionPage getFollowingPage(@PathVariable String userName,
@RequestParam(required = false, defaultValue = "0") int page) {
User user = userService.getUserByName(userName);
@@ -242,7 +242,7 @@ public class Profile {
throw new HttpNotFoundException();
}
- @GetMapping(value = "/n/{mid}-{rid}", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @GetMapping(value = "/n/{mid}-{rid}", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public Context showNote(@PathVariable int mid, @PathVariable int rid) {
if (rid > 0) {
// reply
@@ -253,7 +253,7 @@ public class Profile {
messagesService.getMessage(mid)));
}
- @PostMapping(value = "/api/inbox", consumes = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE})
+ @PostMapping(value = "/api/inbox", consumes = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE})
public ResponseEntity processInbox(@RequestBody Activity activity,
@RequestHeader(name = "Host") String host,
@RequestHeader(name = "Date") String date,
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Context.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Context.java
index 544f1aa2..0df8f8c7 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Context.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Context.java
@@ -71,7 +71,8 @@ public abstract class Context {
public final static String ACTIVITY_STREAMS_URI = "https://www.w3.org/ns/activitystreams";
public final static String SECURITY_URI = "https://w3id.org/security/v1";
public final static String LD_JSON_MEDIA_TYPE = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
- public final static String ACTIVITY_JSON_MEDIA_TYPE = "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"";
+ public final static String ACTIVITY_MEDIA_TYPE = "application/activity+json";
+ public final static String ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE = ACTIVITY_MEDIA_TYPE + "; profile=\"https://www.w3.org/ns/activitystreams\"";
public Instant getPublished() {
return published;
diff --git a/juick-server/src/main/java/com/juick/server/api/webfinger/Resource.java b/juick-server/src/main/java/com/juick/server/api/webfinger/Resource.java
index 2df895fd..71a0ca31 100644
--- a/juick-server/src/main/java/com/juick/server/api/webfinger/Resource.java
+++ b/juick-server/src/main/java/com/juick/server/api/webfinger/Resource.java
@@ -15,6 +15,8 @@ import rocks.xmpp.addr.Jid;
import javax.inject.Inject;
import java.util.Collections;
+import static com.juick.server.api.activity.model.Context.ACTIVITY_MEDIA_TYPE;
+
@RestController
public class Resource {
@Inject
@@ -35,7 +37,7 @@ public class Resource {
builder.path(String.format("/u/%s", user.getName()));
Link blog = new Link();
blog.setRel("self");
- blog.setType("application/activity+json");
+ blog.setType(ACTIVITY_MEDIA_TYPE);
blog.setHref(builder.toUriString());
Account result = new Account();
result.setSubject(resource);
diff --git a/juick-server/src/test/java/com/juick/server/tests/ServerTests.java b/juick-server/src/test/java/com/juick/server/tests/ServerTests.java
index d3654e90..86daff1f 100644
--- a/juick-server/src/test/java/com/juick/server/tests/ServerTests.java
+++ b/juick-server/src/test/java/com/juick/server/tests/ServerTests.java
@@ -18,7 +18,6 @@
package com.juick.server.tests;
import com.fasterxml.jackson.annotation.JsonInclude;
-import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gargoylesoftware.htmlunit.CookieManager;
@@ -38,9 +37,9 @@ import com.juick.server.api.activity.model.activities.Create;
import com.juick.server.api.activity.model.activities.Delete;
import com.juick.server.api.activity.model.activities.Follow;
import com.juick.server.api.activity.model.activities.Undo;
-import com.juick.server.api.activity.model.objects.Mention;
import com.juick.server.api.activity.model.objects.Note;
import com.juick.server.api.activity.model.objects.Person;
+import com.juick.server.api.webfinger.model.Account;
import com.juick.server.util.HttpUtils;
import com.juick.server.util.ImageUtils;
import com.juick.server.xmpp.helpers.XMPPStatus;
@@ -68,17 +67,16 @@ 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.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.io.ClassPathResource;
import org.springframework.http.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
-import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
-import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import org.w3c.dom.Document;
@@ -1432,6 +1430,8 @@ public class ServerTests {
.andExpect(jsonPath("$.links[0].href", is("http://localhost:8080/u/ugnich")));
mockMvc.perform(get("/.well-known/webfinger?resource=acct:durov@localhost"))
.andExpect(status().isNotFound());
+ Person ugnich = (Person) signatureManager.discoverPerson("ugnich@juick.com").get();
+ assertThat(ugnich.getName(), is(ugnichName));
}
@Test
public void userProfileAndBlogShouldBeExposedAsActivityStream() throws Exception {
@@ -1445,7 +1445,7 @@ public class ServerTests {
String.format("message %d", i), null, null))
.collect(Collectors.toCollection(ArrayDeque::new)).descendingIterator());
List midsPage = mids.stream().limit(20).collect(Collectors.toList());
- mockMvc.perform(get("/u/ugnich/blog").accept(Context.ACTIVITY_JSON_MEDIA_TYPE))
+ mockMvc.perform(get("/u/ugnich/blog").accept(Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderedItems", hasSize(20)))
.andExpect(jsonPath("$.next", is("http://localhost:8080/u/ugnich/blog?before=" + midsPage.get(midsPage.size() - 1))));
@@ -1699,19 +1699,19 @@ public class ServerTests {
}
@Test
public void ActivityDeserialization() throws IOException {
- String followJsonStr = IOUtils.toString(URI.create("classpath:follow.json"), StandardCharsets.UTF_8);
+ String followJsonStr = IOUtils.toString(new ClassPathResource("follow.json").getURI(), StandardCharsets.UTF_8);
Follow follow = (Follow)jsonMapper.readValue(followJsonStr, Context.class);
- String personJsonStr = IOUtils.toString(URI.create("classpath:person.json"), StandardCharsets.UTF_8);
+ String personJsonStr = IOUtils.toString(new ClassPathResource("person.json").getURI(), StandardCharsets.UTF_8);
Person person = (Person)jsonMapper.readValue(personJsonStr, Context.class);
- String undoJsonStr = IOUtils.toString(URI.create("classpath:undo.json"), StandardCharsets.UTF_8);
+ String undoJsonStr = IOUtils.toString(new ClassPathResource("undo.json").getURI(), StandardCharsets.UTF_8);
Undo undo = jsonMapper.readValue(undoJsonStr, Undo.class);
String undoFollower = (String)((Map)undo.getObject()).get("object");
- String createJsonStr = IOUtils.toString(URI.create("classpath:create.json"), StandardCharsets.UTF_8);
+ String createJsonStr = IOUtils.toString(new ClassPathResource("create.json").getURI(), StandardCharsets.UTF_8);
Create create = jsonMapper.readValue(createJsonStr, Create.class);
Map note = (Map) create.getObject();
Map attachmentObj = (Map )((List