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) note.get("attachment")).get(0); String attachment = attachmentObj != null ? (String)attachmentObj.get("url") : StringUtils.EMPTY; - String deleteJsonStr = IOUtils.toString(URI.create("classpath:delete.json"), StandardCharsets.UTF_8); + String deleteJsonStr = IOUtils.toString(new ClassPathResource("delete.json").getURI(), StandardCharsets.UTF_8); Delete delete = jsonMapper.readValue(deleteJsonStr, Delete.class); int mid = messagesService.createMessage(ugnich.getUid(), "YO", "", null); User extUser = new User(); @@ -1725,8 +1725,9 @@ public class ServerTests { Message replyToExt = messagesService.getReply(mid, rid2); Note replyNote = activityPubManager.makeNote(replyToExt); assertThat(replyNote.getInReplyTo(), equalTo(extMessageUri)); - String noteStr = IOUtils.toString(URI.create("classpath:mention.json"), StandardCharsets.UTF_8); + String noteStr = IOUtils.toString(new ClassPathResource("mention.json").getURI(), StandardCharsets.UTF_8); Note create2 = jsonMapper.readValue(noteStr, Note.class); + jsonMapper.readValue(IOUtils.toString(new ClassPathResource("webfinger.json").getURI(), StandardCharsets.UTF_8), Account.class); } @Test public void activitySerialization() throws Exception { diff --git a/juick-server/src/test/resources/webfinger.json b/juick-server/src/test/resources/webfinger.json new file mode 100644 index 00000000..55f9e4f3 --- /dev/null +++ b/juick-server/src/test/resources/webfinger.json @@ -0,0 +1,36 @@ +{ + "subject": "acct:Gargron@mastodon.social", + "aliases": [ + "https://mastodon.social/@Gargron", + "https://mastodon.social/users/Gargron" + ], + "links": [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": "https://mastodon.social/@Gargron" + }, + { + "rel": "http://schemas.google.com/g/2010#updates-from", + "type": "application/atom+xml", + "href": "https://mastodon.social/users/Gargron.atom" + }, + { + "rel": "self", + "type": "application/activity+json", + "href": "https://mastodon.social/users/Gargron" + }, + { + "rel": "salmon", + "href": "https://mastodon.social/api/salmon/1" + }, + { + "rel": "magic-public-key", + "href": "data:application/magic-public-key,RSA.vXc4vkECU2_CeuSo1wtnFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY_4q_S9uccrE9Bkajv1dnkOVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ymovljWGSA_jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz2s2G8qKv8fyimE23gY1XrPJg-cRF-g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3xBfIdPythWu5b4cujNsB3m3awJjVmx-MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFRTw==.AQAB" + }, + { + "rel": "http://ostatus.org/schema/1.0/subscribe", + "template": "https://mastodon.social/authorize_interaction?uri={uri}" + } + ] +} -- cgit v1.2.3