aboutsummaryrefslogtreecommitdiff
path: root/juick-server/src
diff options
context:
space:
mode:
authorGravatar Vitaly Takmazov2018-10-01 17:58:46 +0300
committerGravatar Vitaly Takmazov2018-10-03 09:06:00 +0300
commitbac87790c6d044e3bfe9781dd285dfa4b33e49ee (patch)
treecafe620a09bf41c85a5c6512ee2611f45b0ab3c1 /juick-server/src
parente04371500a9dd469f02024f63ef39117f8a4d649 (diff)
ActivityPub: HTTP Signatures and autoaccept followers
Diffstat (limited to 'juick-server/src')
-rw-r--r--juick-server/src/main/java/com/juick/server/ActivityPubManager.java37
-rw-r--r--juick-server/src/main/java/com/juick/server/KeystoreManager.java27
-rw-r--r--juick-server/src/main/java/com/juick/server/SignatureManager.java79
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/Profile.java82
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Activity.java23
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/ActivityObject.java52
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Context.java82
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Create.java34
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Image.java2
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Key.java2
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Link.java2
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Note.java2
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollection.java2
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollectionPage.java8
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/Person.java6
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/activities/Accept.java6
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/activities/Create.java6
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/activities/Delete.java6
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/activities/Follow.java6
-rw-r--r--juick-server/src/main/java/com/juick/server/api/activity/model/activities/Undo.java6
-rw-r--r--juick-server/src/main/java/com/juick/server/configuration/SecurityConfig.java2
-rw-r--r--juick-server/src/main/java/com/juick/service/activities/FollowEvent.java21
-rw-r--r--juick-server/src/test/java/com/juick/server/tests/ServerTests.java39
-rw-r--r--juick-server/src/test/resources/follow.json42
-rw-r--r--juick-server/src/test/resources/person.json54
-rw-r--r--juick-server/src/test/resources/undo.json47
26 files changed, 549 insertions, 126 deletions
diff --git a/juick-server/src/main/java/com/juick/server/ActivityPubManager.java b/juick-server/src/main/java/com/juick/server/ActivityPubManager.java
new file mode 100644
index 00000000..362754fd
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/ActivityPubManager.java
@@ -0,0 +1,37 @@
+package com.juick.server;
+
+import com.juick.server.api.activity.model.Person;
+import com.juick.server.api.activity.model.activities.Accept;
+import com.juick.service.activities.FollowEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationListener;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Nonnull;
+import javax.inject.Inject;
+import java.io.IOException;
+import java.net.URI;
+
+@Component
+public class ActivityPubManager implements ApplicationListener<FollowEvent> {
+ private static final Logger logger = LoggerFactory.getLogger(ActivityPubManager.class);
+ @Inject
+ SignatureManager signatureManager;
+ @Override
+ public void onApplicationEvent(@Nonnull FollowEvent followEvent) {
+ logger.info("received follower request");
+ // automatically accept follower requests
+ Person me = (Person) signatureManager.getContext(URI.create((String)followEvent.getRequest().getObject()));
+ Person follower = (Person) signatureManager.getContext(URI.create(followEvent.getRequest().getActor()));
+ Accept accept = new Accept();
+ accept.setActor(me.getId());
+ accept.setObject(followEvent.getRequest());
+ try {
+ signatureManager.post(me, follower, accept);
+ } catch (IOException e) {
+ logger.info("activitypub exception", e);
+ }
+
+ }
+}
diff --git a/juick-server/src/main/java/com/juick/server/KeystoreManager.java b/juick-server/src/main/java/com/juick/server/KeystoreManager.java
index 75d72e72..855052c4 100644
--- a/juick-server/src/main/java/com/juick/server/KeystoreManager.java
+++ b/juick-server/src/main/java/com/juick/server/KeystoreManager.java
@@ -1,5 +1,6 @@
package com.juick.server;
+import com.juick.server.api.activity.model.Person;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -14,8 +15,11 @@ import java.io.InputStream;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
+import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
+import java.util.List;
import java.util.stream.Collectors;
+import java.util.stream.IntStream;
@Component
public class KeystoreManager {
@@ -61,9 +65,30 @@ public class KeystoreManager {
}
return null;
}
- public String getPublicKey() {
+ public PrivateKey getPrivateKey() {
+ return getKeyPair().getPrivate();
+ }
+ public PublicKey getPublicKey() {
+ return getKeyPair().getPublic();
+ }
+ public String getPublicKeyPem() {
String[] key = Base64Utils.encodeToString(getKeyPair().getPublic().getEncoded()).split("(?<=\\G.{64})");
return String.format("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n",
Arrays.asList(key).stream().collect(Collectors.joining("\n")));
}
+ public static PublicKey publicKeyOf(Person person) {
+ String pubkeyPem = person.getPublicKey().getPublicKeyPem();
+ String[] rawKey = pubkeyPem.split("\\n");
+ String pubkeyData = String.join("", Arrays.asList(rawKey).subList(1, rawKey.length - 1));
+ try{
+ byte[] byteKey = Base64Utils.decodeFromString(pubkeyData);
+ X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(byteKey);
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ return kf.generatePublic(X509publicKey);
+ }
+ catch(Exception e){
+ e.printStackTrace();
+ }
+ return null;
+ }
}
diff --git a/juick-server/src/main/java/com/juick/server/SignatureManager.java b/juick-server/src/main/java/com/juick/server/SignatureManager.java
new file mode 100644
index 00000000..d89919f0
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/SignatureManager.java
@@ -0,0 +1,79 @@
+package com.juick.server;
+
+import com.juick.server.api.activity.model.Context;
+import com.juick.server.api.activity.model.Person;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+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 javax.inject.Inject;
+import java.io.IOException;
+import java.net.URI;
+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;
+
+@Component
+public class SignatureManager {
+ private static final Logger logger = LoggerFactory.getLogger(ActivityPubManager.class);
+ @Inject
+ private KeystoreManager keystoreManager;
+
+ public void post(Person from, Person to, Context data) throws IOException {
+ UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(to.getInbox());
+ URI inbox = uriComponentsBuilder.build().toUri();
+ Instant now = Instant.now();
+ String requestDate = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneId.of("UTC")).format(now);
+ Signature templateSignature = new Signature(from.getPublicKey().getId(), "rsa-sha256", null,
+ "(request-target)", "host", "date");
+ Signer signer = new Signer(keystoreManager.getPrivateKey(), templateSignature);
+ Map<String, String> headers = new HashMap<>();
+ headers.put("host", inbox.getHost());
+ 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("Date", requestDate);
+ requestHeaders.add("Signature", signature.toString().substring(10));
+ HttpEntity<Context> request = new HttpEntity<>(Context.build(data), requestHeaders);
+ //boolean valid = verifySignature(Signature.fromString(requestHeaders.getFirst("Signature")),
+ // keystoreManager.getPublicKey(), "POST", inbox.getPath(), headers);
+ ResponseEntity<Void> response = new RestTemplate().postForEntity(inbox, request, Void.class);
+ logger.info("accepted follower: {}", response.getStatusCode().is2xxSuccessful());
+ }
+ public boolean verifySignature(String signatureString, URI actor, String method, String path, Map<String, String> headers) {
+ Context context = getContext(actor);
+ if (context instanceof Person) {
+ Person person = (Person) context;
+ Key key = KeystoreManager.publicKeyOf(person);
+ logger.info("data signed by person with key {}", key);
+ Verifier verifier = new Verifier(key, Signature.fromString(signatureString));
+ try {
+ boolean result = verifier.verify(method, path, headers);
+ logger.info("signature is valid: {}", result);
+ return result;
+ } catch (NoSuchAlgorithmException | SignatureException | IOException e) {
+ logger.info("signature exception", e);
+ return false;
+ }
+ }
+ logger.info("person not found");
+ return false;
+ }
+ public Context getContext(URI contextUri) {
+ return new RestTemplate().getForEntity(contextUri, Context.class).getBody();
+ }
+}
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 9f98b4ea..656d85dd 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
@@ -2,24 +2,33 @@ package com.juick.server.api.activity;
import com.juick.User;
import com.juick.server.KeystoreManager;
+import com.juick.server.SignatureManager;
import com.juick.server.api.activity.model.*;
+import com.juick.server.api.activity.model.activities.Create;
+import com.juick.server.api.activity.model.activities.Follow;
+import com.juick.server.api.activity.model.activities.Undo;
import com.juick.server.util.HttpNotFoundException;
import com.juick.server.util.UserUtils;
import com.juick.service.MessagesService;
import com.juick.service.UserService;
+import com.juick.service.activities.FollowEvent;
import com.juick.util.MessageUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import javax.inject.Inject;
+import java.net.URI;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -31,6 +40,10 @@ public class Profile {
private MessagesService messagesService;
@Inject
private KeystoreManager keystoreManager;
+ @Inject
+ private SignatureManager signatureManager;
+ @Inject
+ private ApplicationEventPublisher applicationEventPublisher;
@Value("${web_domain:localhost}")
private String domain;
@Value("${ap_base_uri:http://localhost:8080/}")
@@ -38,7 +51,7 @@ public class Profile {
@Value("${img_url:http://localhost:8080/i/}")
private String baseImagesUri;
- @GetMapping(value = "/u/{userName}", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public Person getUser(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -52,9 +65,9 @@ public class Profile {
Key publicKey = new Key();
publicKey.setId(person.getId() + "#main-key");
publicKey.setOwner(person.getId());
- publicKey.setPublicKeyPem(keystoreManager.getPublicKey());
+ publicKey.setPublicKeyPem(keystoreManager.getPublicKeyPem());
person.setPublicKey(publicKey);
- uri.replacePath("/post");
+ uri.replacePath("/api/inbox");
person.setInbox(uri.toUriString());
person.setOutbox(uri.replacePath(String.format("/u/%s/blog/toc", userName)).toUriString());
person.setFollowers(uri.replacePath(String.format("/u/%s/followers/toc", userName)).toUriString());
@@ -65,11 +78,11 @@ public class Profile {
avatar.setUrl(image.toUriString());
avatar.setMediaType("image/png");
person.setIcon(avatar);
- return (Person) ActivityObject.build(person);
+ return (Person) Context.build(person);
}
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/blog/toc", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}/blog/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public OrderedCollection getOutbox(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -78,11 +91,11 @@ public class Profile {
blog.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
blog.setTotalItems(userService.getStatsMessages(user.getUid()));
blog.setFirst(uriComponentsBuilder.path(String.format("/u/%s/blog", userName)).toUriString());
- return (OrderedCollection) ActivityObject.build(blog);
+ return (OrderedCollection) Context.build(blog);
}
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/blog", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}/blog", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public OrderedCollectionPage getOutboxPage(@PathVariable String userName,
@RequestParam(required = false, defaultValue = "0") int before) {
User visitor = UserUtils.getCurrentUser();
@@ -127,11 +140,11 @@ public class Profile {
page.setNext(uri.queryParam("before", beforeNext).toUriString());
}
page.setLast(uri.replaceQueryParam("before", "1").toUriString());
- return (OrderedCollectionPage) ActivityObject.build(page);
+ return (OrderedCollectionPage) Context.build(page);
}
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/followers/toc", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}/followers/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public OrderedCollection getFollowers(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -140,11 +153,11 @@ public class Profile {
followers.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
followers.setTotalItems(userService.getStatsMyReaders(user.getUid()));
followers.setFirst(uriComponentsBuilder.path(String.format("/u/%s/followers", userName)).toUriString());
- return (OrderedCollection) ActivityObject.build(followers);
+ return (OrderedCollection) Context.build(followers);
}
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/followers", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}/followers", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public OrderedCollectionPage getFollowersPage(@PathVariable String userName,
@RequestParam(required = false, defaultValue = "0") int page) {
User user = userService.getUserByName(userName);
@@ -169,11 +182,11 @@ public class Profile {
if (hasNext) {
result.setNext(uriComponentsBuilder.queryParam("page", page + 1).toUriString());
}
- return (OrderedCollectionPage) ActivityObject.build(result);
+ return (OrderedCollectionPage) Context.build(result);
}
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/following/toc", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}/following/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public OrderedCollection getFollowing(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
@@ -182,11 +195,11 @@ public class Profile {
following.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
following.setTotalItems(userService.getUserFriends(user.getUid()).size());
following.setFirst(uriComponentsBuilder.path(String.format("/u/%s/followers", userName)).toUriString());
- return (OrderedCollection) ActivityObject.build(following);
+ return (OrderedCollection) Context.build(following);
}
throw new HttpNotFoundException();
}
- @GetMapping(value = "/u/{userName}/following", produces = { ActivityObject.LD_JSON_MEDIA_TYPE, ActivityObject.ACTIVITY_JSON_MEDIA_TYPE })
+ @GetMapping(value = "/u/{userName}/following", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
public OrderedCollectionPage getFollowingPage(@PathVariable String userName,
@RequestParam(required = false, defaultValue = "0") int page) {
User user = userService.getUserByName(userName);
@@ -211,8 +224,37 @@ public class Profile {
if (hasNext) {
result.setNext(uriComponentsBuilder.queryParam("page", page + 1).toUriString());
}
- return (OrderedCollectionPage) ActivityObject.build(result);
+ return (OrderedCollectionPage) Context.build(result);
}
throw new HttpNotFoundException();
}
+ @PostMapping(value = "/api/inbox", consumes = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_JSON_MEDIA_TYPE })
+ public ResponseEntity<Void> processInbox(@RequestBody Context activity,
+ @RequestHeader(name = "Host") String host,
+ @RequestHeader(name = "Date") String date,
+ @RequestHeader(name = "Digest") String digest,
+ @RequestHeader(name = "Content-Type") String contentType,
+ @RequestHeader(name = "Signature") String signature) {
+ if (activity instanceof Follow) {
+ Follow followRequest = (Follow) activity;
+ UriComponents componentsBuilder = ServletUriComponentsBuilder.fromCurrentRequestUri().build();
+ Map<String, String> headers = new HashMap<>();
+ headers.put("host", host);
+ headers.put("date", date);
+ headers.put("digest", digest);
+ headers.put("content-type", contentType);
+ boolean valid = signatureManager.verifySignature(signature, URI.create(followRequest.getActor()), "POST",
+ componentsBuilder.getPath(), headers);
+ if (valid) {
+ applicationEventPublisher.publishEvent(
+ new FollowEvent(this, followRequest));
+ return new ResponseEntity<>(HttpStatus.ACCEPTED);
+ }
+ return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
+ }
+ if (activity instanceof Undo) {
+ return new ResponseEntity<>(HttpStatus.OK);
+ }
+ return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
+ }
}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Activity.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Activity.java
new file mode 100644
index 00000000..ec126b88
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Activity.java
@@ -0,0 +1,23 @@
+package com.juick.server.api.activity.model;
+
+public abstract class Activity extends Context {
+
+ private String actor;
+ private Object object;
+
+ public String getActor() {
+ return actor;
+ }
+
+ public void setActor(String actor) {
+ this.actor = actor;
+ }
+
+ public Object getObject() {
+ return object;
+ }
+
+ public void setObject(Object object) {
+ this.object = object;
+ }
+}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/ActivityObject.java b/juick-server/src/main/java/com/juick/server/api/activity/model/ActivityObject.java
deleted file mode 100644
index fceb3612..00000000
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/ActivityObject.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.juick.server.api.activity.model;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-import java.time.Instant;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-public abstract class ActivityObject {
-
- private List<String> context;
-
- private String id;
-
- private Instant published;
-
- public String getId() {
- return id;
- }
-
- public void setId(String id) {
- this.id = id;
- }
-
- public String getType() {
- return getClass().getSimpleName();
- }
-
- @JsonProperty("@context")
- public List<String> getContext() {
- return 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 Instant getPublished() {
- return published;
- }
-
- public void setPublished(Instant published) {
- this.published = published;
- }
-
- public static ActivityObject build(ActivityObject response) {
- response.context = Arrays.asList(ACTIVITY_STREAMS_URI, SECURITY_URI);
- return response;
- }
-}
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
new file mode 100644
index 00000000..984eb2cd
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Context.java
@@ -0,0 +1,82 @@
+package com.juick.server.api.activity.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.juick.server.api.activity.model.activities.*;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property="type")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = Create.class, name = "Create"),
+ @JsonSubTypes.Type(value = Delete.class, name = "Delete"),
+ @JsonSubTypes.Type(value = Follow.class, name = "Follow"),
+ @JsonSubTypes.Type(value = Accept.class, name = "Accept"),
+ @JsonSubTypes.Type(value = Undo.class, name = "Undo"),
+ @JsonSubTypes.Type(value = Image.class, name = "Image"),
+ @JsonSubTypes.Type(value = Key.class, name = "Key"),
+ @JsonSubTypes.Type(value = Link.class, name = "Link"),
+ @JsonSubTypes.Type(value = Note.class, name = "Note"),
+ @JsonSubTypes.Type(value = OrderedCollection.class, name = "OrderedCollection"),
+ @JsonSubTypes.Type(value = OrderedCollectionPage.class, name = "OrderedCollectionPage"),
+ @JsonSubTypes.Type(value = Person.class, name = "Person")
+})
+public abstract class Context {
+
+ private List<Object> context;
+
+ private String id;
+
+ private Instant published;
+
+
+ private List<String> to;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getType() {
+ return getClass().getSimpleName();
+ }
+
+ @JsonProperty("@context")
+ public List<Object> getContext() {
+ return 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 Instant getPublished() {
+ return published;
+ }
+
+ public void setPublished(Instant published) {
+ this.published = published;
+ }
+
+ public List<String> getTo() {
+ return to;
+ }
+
+ public void setTo(List<String> to) {
+ this.to = to;
+ }
+
+ public static Context build(Context response) {
+ response.context = Arrays.asList(ACTIVITY_STREAMS_URI, SECURITY_URI);
+ return response;
+ }
+}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Create.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Create.java
deleted file mode 100644
index 2acdd6a6..00000000
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Create.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.juick.server.api.activity.model;
-
-import java.util.List;
-
-public class Create extends ActivityObject {
-
- private String actor;
- private Note object;
- private List<String> to;
-
- public String getActor() {
- return actor;
- }
-
- public void setActor(String actor) {
- this.actor = actor;
- }
-
- public Note getObject() {
- return object;
- }
-
- public void setObject(Note object) {
- this.object = object;
- }
-
- public List<String> getTo() {
- return to;
- }
-
- public void setTo(List<String> to) {
- this.to = to;
- }
-}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Image.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Image.java
index 9a3b1659..c8f869a5 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Image.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Image.java
@@ -1,6 +1,6 @@
package com.juick.server.api.activity.model;
-public class Image extends ActivityObject {
+public class Image extends Context {
private String mediaType;
private String url;
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Key.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Key.java
index 32417778..bc41b460 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Key.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Key.java
@@ -1,6 +1,6 @@
package com.juick.server.api.activity.model;
-public class Key extends ActivityObject {
+public class Key extends Context {
private String owner;
private String publicKeyPem;
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Link.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Link.java
index b57dabbe..543b5f0c 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Link.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Link.java
@@ -1,6 +1,6 @@
package com.juick.server.api.activity.model;
-public class Link extends ActivityObject {
+public class Link extends Context {
private String href;
public String getHref() {
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Note.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Note.java
index ac58b033..ff64c4b9 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Note.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Note.java
@@ -2,7 +2,7 @@ package com.juick.server.api.activity.model;
import java.util.List;
-public class Note extends ActivityObject {
+public class Note extends Context {
private String content;
private String attributedTo;
private Link attachment;
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollection.java b/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollection.java
index 90f04de3..d66c55be 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollection.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollection.java
@@ -1,6 +1,6 @@
package com.juick.server.api.activity.model;
-public class OrderedCollection extends ActivityObject {
+public class OrderedCollection extends Context {
private int totalItems;
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollectionPage.java b/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollectionPage.java
index af7f2cec..bcae87d0 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollectionPage.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/OrderedCollectionPage.java
@@ -2,7 +2,7 @@ package com.juick.server.api.activity.model;
import java.util.List;
-public class OrderedCollectionPage extends ActivityObject {
+public class OrderedCollectionPage extends Context {
private String partOf;
@@ -12,7 +12,7 @@ public class OrderedCollectionPage extends ActivityObject {
private String last;
- private List<? extends ActivityObject> orderedItems;
+ private List<? extends Context> orderedItems;
public String getNext() {
return next;
@@ -22,11 +22,11 @@ public class OrderedCollectionPage extends ActivityObject {
this.next = next;
}
- public List<? extends ActivityObject> getOrderedItems() {
+ public List<? extends Context> getOrderedItems() {
return orderedItems;
}
- public void setOrderedItems(List<? extends ActivityObject> orderedItems) {
+ public void setOrderedItems(List<? extends Context> orderedItems) {
this.orderedItems = orderedItems;
}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/Person.java b/juick-server/src/main/java/com/juick/server/api/activity/model/Person.java
index e314624d..8a817c18 100644
--- a/juick-server/src/main/java/com/juick/server/api/activity/model/Person.java
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/Person.java
@@ -1,6 +1,8 @@
package com.juick.server.api.activity.model;
-public class Person extends ActivityObject {
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+public class Person extends Context {
private String name;
private String preferredUsername;
@@ -25,6 +27,7 @@ public class Person extends ActivityObject {
this.name = name;
}
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
public Image getIcon() {
return icon;
}
@@ -73,6 +76,7 @@ public class Person extends ActivityObject {
this.url = url;
}
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
public Key getPublicKey() {
return publicKey;
}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Accept.java b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Accept.java
new file mode 100644
index 00000000..1e0a9968
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Accept.java
@@ -0,0 +1,6 @@
+package com.juick.server.api.activity.model.activities;
+
+import com.juick.server.api.activity.model.Activity;
+
+public class Accept extends Activity {
+}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Create.java b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Create.java
new file mode 100644
index 00000000..52507373
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Create.java
@@ -0,0 +1,6 @@
+package com.juick.server.api.activity.model.activities;
+
+import com.juick.server.api.activity.model.Activity;
+
+public class Create extends Activity {
+}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Delete.java b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Delete.java
new file mode 100644
index 00000000..f4392020
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Delete.java
@@ -0,0 +1,6 @@
+package com.juick.server.api.activity.model.activities;
+
+import com.juick.server.api.activity.model.Activity;
+
+public class Delete extends Activity {
+}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Follow.java b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Follow.java
new file mode 100644
index 00000000..573ecc6e
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Follow.java
@@ -0,0 +1,6 @@
+package com.juick.server.api.activity.model.activities;
+
+import com.juick.server.api.activity.model.Activity;
+
+public class Follow extends Activity {
+}
diff --git a/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Undo.java b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Undo.java
new file mode 100644
index 00000000..4e87e9d0
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/server/api/activity/model/activities/Undo.java
@@ -0,0 +1,6 @@
+package com.juick.server.api.activity.model.activities;
+
+import com.juick.server.api.activity.model.Activity;
+
+public class Undo extends Activity {
+}
diff --git a/juick-server/src/main/java/com/juick/server/configuration/SecurityConfig.java b/juick-server/src/main/java/com/juick/server/configuration/SecurityConfig.java
index 23e2a4e6c..9ce8621e 100644
--- a/juick-server/src/main/java/com/juick/server/configuration/SecurityConfig.java
+++ b/juick-server/src/main/java/com/juick/server/configuration/SecurityConfig.java
@@ -97,7 +97,7 @@ public class SecurityConfig {
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.antMatchers("/api/", "/api/messages", "/api/messages/discussions", "/api/users", "/api/thread", "/api/tags", "/api/tlgmbtwbhk", "/api/fbwbhk",
- "/api/skypebotendpoint", "/api/_fblogin", "/api/_vklogin", "/api/_tglogin", "/api/u/**", "/.well-known/webfinger", "/rss/**").permitAll()
+ "/api/skypebotendpoint", "/api/_fblogin", "/api/_vklogin", "/api/_tglogin", "/api/inbox", "/api/u/**", "/.well-known/webfinger", "/rss/**").permitAll()
.anyRequest().hasRole("USER")
.and()
.anonymous().principal(JuickUser.ANONYMOUS_USER).authorities(JuickUser.ANONYMOUS_AUTHORITY)
diff --git a/juick-server/src/main/java/com/juick/service/activities/FollowEvent.java b/juick-server/src/main/java/com/juick/service/activities/FollowEvent.java
new file mode 100644
index 00000000..c96613ba
--- /dev/null
+++ b/juick-server/src/main/java/com/juick/service/activities/FollowEvent.java
@@ -0,0 +1,21 @@
+package com.juick.service.activities;
+
+import com.juick.server.api.activity.model.activities.Follow;
+import org.springframework.context.ApplicationEvent;
+
+public class FollowEvent extends ApplicationEvent {
+ private Follow request;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public FollowEvent(Object source, Follow followRequest) {
+ super(source);
+ this.request = followRequest;
+ }
+
+ public Follow getRequest() {
+ return request;
+ }
+}
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 0f483acc..c697ffc6 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
@@ -27,18 +27,21 @@ import com.gargoylesoftware.htmlunit.html.DomElement;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.jayway.jsonpath.JsonPath;
import com.juick.*;
-import com.juick.server.*;
-import com.juick.server.api.activity.model.ActivityObject;
-import com.juick.service.component.MessageEvent;
import com.juick.model.AnonymousUser;
import com.juick.model.CommandResult;
import com.juick.model.TagStats;
+import com.juick.server.*;
+import com.juick.server.api.activity.model.Context;
+import com.juick.server.api.activity.model.Key;
+import com.juick.server.api.activity.model.Person;
+import com.juick.server.api.activity.model.activities.Follow;
import com.juick.server.util.HttpUtils;
import com.juick.server.util.ImageUtils;
import com.juick.server.www.Utils;
import com.juick.server.xmpp.helpers.XMPPStatus;
import com.juick.server.xmpp.s2s.ConnectionIn;
import com.juick.service.*;
+import com.juick.service.component.MessageEvent;
import com.juick.util.DateFormattersHolder;
import com.juick.util.MessageUtils;
import com.mitchellbosecke.pebble.PebbleEngine;
@@ -60,10 +63,8 @@ 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.mock.web.MockMultipartFile;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
@@ -193,6 +194,8 @@ public class ServerTests {
private PebbleEngine pebbleEngine;
@Value("${ios_app_id:}")
private String appId;
+ @Inject
+ private SignatureManager signatureManager;
private static User ugnich, freefd, juick;
static String ugnichName, ugnichPassword, freefdName, freefdPassword, juickName, juickPassword;
@@ -1419,17 +1422,17 @@ public class ServerTests {
}
@Test
public void userProfileAndBlogShouldBeExposedAsActivityStream() throws Exception {
- mockMvc.perform(get("/u/ugnich").accept(ActivityObject.LD_JSON_MEDIA_TYPE))
+ mockMvc.perform(get("/u/ugnich").accept(Context.LD_JSON_MEDIA_TYPE))
.andExpect(status().isOk())
.andExpect(jsonPath("$.icon.url", is("http://localhost:8080/i/a/1.png")))
- .andExpect(jsonPath("$.publicKey.publicKeyPem", is(keystoreManager.getPublicKey())));
+ .andExpect(jsonPath("$.publicKey.publicKeyPem", is(keystoreManager.getPublicKeyPem())));
jdbcTemplate.execute("DELETE FROM messages");
List<Integer> mids = IteratorUtils.toList(IntStream.rangeClosed(1, 30)
.mapToObj(i -> messagesService.createMessage(ugnich.getUid(),
String.format("message %d", i), null, null))
.collect(Collectors.toCollection(ArrayDeque::new)).descendingIterator());
List<Integer> midsPage = mids.stream().limit(20).collect(Collectors.toList());
- mockMvc.perform(get("/u/ugnich/blog").accept(ActivityObject.ACTIVITY_JSON_MEDIA_TYPE))
+ mockMvc.perform(get("/u/ugnich/blog").accept(Context.ACTIVITY_JSON_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))));
@@ -1676,4 +1679,24 @@ public class ServerTests {
.param("hash", userService.getHashByUID(ugnich.getUid()))
.param("List-Unsubscribe", "One-Click")).andExpect(status().isOk());
}
+ @Test
+ public void ActivityDeserialization() throws IOException {
+ String followJsonStr = IOUtils.toString(URI.create("classpath:follow.json"), StandardCharsets.UTF_8);
+ Follow follow = (Follow)jsonMapper.readValue(followJsonStr, Context.class);
+ String personJsonStr = IOUtils.toString(URI.create("classpath:person.json"), StandardCharsets.UTF_8);
+ Person person = (Person)jsonMapper.readValue(personJsonStr, Context.class);
+ }
+ @Test
+ public void signingSpec() throws IOException {
+ Key fromKey = new Key();
+ fromKey.setId("to-key-id");
+ Person from = new Person();
+ from.setPublicKey(fromKey);
+ Person to = new Person();
+ to.setInbox("http://localhost:8080/api/inbox");
+ Follow follow = new Follow();
+ follow.setActor("http://localhost:8080/u/freefd");
+ follow.setObject("http://localhost:8080/u/ugnich");
+ signatureManager.post(from, to, follow);
+ }
}
diff --git a/juick-server/src/test/resources/follow.json b/juick-server/src/test/resources/follow.json
new file mode 100644
index 00000000..93d46c24
--- /dev/null
+++ b/juick-server/src/test/resources/follow.json
@@ -0,0 +1,42 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "sensitive": "as:sensitive",
+ "movedTo": {
+ "@id": "as:movedTo",
+ "@type": "@id"
+ },
+ "Hashtag": "as:Hashtag",
+ "ostatus": "http://ostatus.org#",
+ "atomUri": "ostatus:atomUri",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "toot": "http://joinmastodon.org/ns#",
+ "Emoji": "toot:Emoji",
+ "focalPoint": {
+ "@container": "@list",
+ "@id": "toot:focalPoint"
+ },
+ "featured": {
+ "@id": "toot:featured",
+ "@type": "@id"
+ },
+ "schema": "http://schema.org#",
+ "PropertyValue": "schema:PropertyValue",
+ "value": "schema:value"
+ }
+ ],
+ "id": "https://mastodon.social/970f0d76-9ea7-46cd-a3e9-278e255f082d",
+ "type": "Follow",
+ "actor": "https://mastodon.social/users/xwatt",
+ "object": "https://x.juick.com/u/gegege",
+ "signature": {
+ "type": "RsaSignature2017",
+ "creator": "https://mastodon.social/users/xwatt#main-key",
+ "created": "2018-10-02T09:39:29Z",
+ "signatureValue": "lkxaKueSjT0nxCnT15hR8e1yQ7RsUCF0gBaiSAtXmN0tT3g7OcQPZUzUvCFF2aXB8xGFHMv+7Rp+jegR8rszuNRIghUxsOfYL5da2mD5UrpIlxiW4FxZjbni0klUF9GhRWfBYLIMumUsl9UElZPxtpYjlDQ7kCzYqnwbGgUiI0ehBJrHQJHET0pcyeSdGoRlXwD3I4c59nbr22CT026FBRNSJIxJj865ij5vg0j0q0/2ep+8ztya3x0+aYSrFn8WGO4Y2muCJtKurH2ROv8yyVgaIyFaUx6uvBf6pO3oGfWrm5if0P924LLlReXBItbleZrp0y2jPE7RriZsZmuFbg=="
+ }
+} \ No newline at end of file
diff --git a/juick-server/src/test/resources/person.json b/juick-server/src/test/resources/person.json
new file mode 100644
index 00000000..67e88257
--- /dev/null
+++ b/juick-server/src/test/resources/person.json
@@ -0,0 +1,54 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "sensitive": "as:sensitive",
+ "movedTo": {
+ "@id": "as:movedTo",
+ "@type": "@id"
+ },
+ "Hashtag": "as:Hashtag",
+ "ostatus": "http://ostatus.org#",
+ "atomUri": "ostatus:atomUri",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "toot": "http://joinmastodon.org/ns#",
+ "Emoji": "toot:Emoji",
+ "focalPoint": {
+ "@container": "@list",
+ "@id": "toot:focalPoint"
+ },
+ "featured": {
+ "@id": "toot:featured",
+ "@type": "@id"
+ },
+ "schema": "http://schema.org#",
+ "PropertyValue": "schema:PropertyValue",
+ "value": "schema:value"
+ }
+ ],
+ "id": "https://mastodon.social/users/xwatt",
+ "type": "Person",
+ "following": "https://mastodon.social/users/xwatt/following",
+ "followers": "https://mastodon.social/users/xwatt/followers",
+ "inbox": "https://mastodon.social/users/xwatt/inbox",
+ "outbox": "https://mastodon.social/users/xwatt/outbox",
+ "featured": "https://mastodon.social/users/xwatt/collections/featured",
+ "preferredUsername": "xwatt",
+ "name": "",
+ "summary": "\u003cp\u003e\u003c/p\u003e",
+ "url": "https://mastodon.social/@xwatt",
+ "manuallyApprovesFollowers": false,
+ "publicKey": {
+ "id": "https://mastodon.social/users/xwatt#main-key",
+ "owner": "https://mastodon.social/users/xwatt",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuCYg1WrzerteFgLcbnRC\n1/pL5jFY05iuB4ycRlxxCpwpDihKdcmJ8nVEpFc/CIRtRRA3Oq1a+yF4L5X3Bwi8\nA8ajKHNtiPd4eeGdGvEJidf8cR8Bmfmrzt669Tmja+5Cr1CaFX9mYXhQoY6CqIxR\nDrPAAUb0CHJV+Ta6QkieCaGxYsvdg6Gg+8aw6k60vBswS6fmNsykH9xrovqtBb9M\ncKglyOA2W2FgswYtzRRKT5QQU4x/hfWMYuIEMhnke3U5k2gzb/qnJM2otaR0NzJ0\nkW+Fu7av5E6Ur1sUe1hTHpDxaAmNC+br19wTn6zh4Wt1UJp+Os56hBTSq50WKcfW\nhQIDAQAB\n-----END PUBLIC KEY-----\n"
+ },
+ "tag": [],
+ "attachment": [],
+ "endpoints": {
+ "sharedInbox": "https://mastodon.social/inbox"
+ }
+} \ No newline at end of file
diff --git a/juick-server/src/test/resources/undo.json b/juick-server/src/test/resources/undo.json
new file mode 100644
index 00000000..371c6bd6
--- /dev/null
+++ b/juick-server/src/test/resources/undo.json
@@ -0,0 +1,47 @@
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "sensitive": "as:sensitive",
+ "movedTo": {
+ "@id": "as:movedTo",
+ "@type": "@id"
+ },
+ "Hashtag": "as:Hashtag",
+ "ostatus": "http://ostatus.org#",
+ "atomUri": "ostatus:atomUri",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "toot": "http://joinmastodon.org/ns#",
+ "Emoji": "toot:Emoji",
+ "focalPoint": {
+ "@container": "@list",
+ "@id": "toot:focalPoint"
+ },
+ "featured": {
+ "@id": "toot:featured",
+ "@type": "@id"
+ },
+ "schema": "http://schema.org#",
+ "PropertyValue": "schema:PropertyValue",
+ "value": "schema:value"
+ }
+ ],
+ "id": "https://mastodon.social/users/xwatt#follows/1553133/undo",
+ "type": "Undo",
+ "actor": "https://mastodon.social/users/xwatt",
+ "object": {
+ "id": "https://mastodon.social/c5cfbc0b-3e4b-423a-aa51-1c38e1202371",
+ "type": "Follow",
+ "actor": "https://mastodon.social/users/xwatt",
+ "object": "https://x.juick.com/u/gegege"
+ },
+ "signature": {
+ "type": "RsaSignature2017",
+ "creator": "https://mastodon.social/users/xwatt#main-key",
+ "created": "2018-10-02T10:27:33Z",
+ "signatureValue": "OOK3WDLgVMo6u1l/I1o4hrcOf12X2ryOa/xAeuYf7ncKQJbzFXohUYCrBtgUKjnOQJLHY/HbhhFE1SBXMCMUbPNF8KeztxWKqWnXtXNSHVv6Shxl+9yxZoGaAgpkYjn9qQGTAm4SXmV0RM49cz84PfLAJI1fUecoVclhVWXJuaSz4yZ/UteySjBMB5h5nPWGDmz4usviZ9EZTihr3xkFfkg+CPYwr5SYWDe5W1MxeKoosEck6Yhwt8OOf/eGB5guN81lp90O76oO6LGhblnYhMKxsOJf4ahF9qeCNajXk/qiwSfl6IwVADYlFB1GGTdIPXUUob5HhjX27Bpyah0lgA=="
+ }
+} \ No newline at end of file