From bac87790c6d044e3bfe9781dd285dfa4b33e49ee Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Mon, 1 Oct 2018 17:58:46 +0300 Subject: ActivityPub: HTTP Signatures and autoaccept followers --- build.gradle | 2 +- juick-server/build.gradle | 1 + .../java/com/juick/server/ActivityPubManager.java | 37 ++++++++++ .../java/com/juick/server/KeystoreManager.java | 27 ++++++- .../java/com/juick/server/SignatureManager.java | 79 +++++++++++++++++++++ .../com/juick/server/api/activity/Profile.java | 82 ++++++++++++++++------ .../juick/server/api/activity/model/Activity.java | 23 ++++++ .../server/api/activity/model/ActivityObject.java | 52 -------------- .../juick/server/api/activity/model/Context.java | 82 ++++++++++++++++++++++ .../juick/server/api/activity/model/Create.java | 34 --------- .../com/juick/server/api/activity/model/Image.java | 2 +- .../com/juick/server/api/activity/model/Key.java | 2 +- .../com/juick/server/api/activity/model/Link.java | 2 +- .../com/juick/server/api/activity/model/Note.java | 2 +- .../api/activity/model/OrderedCollection.java | 2 +- .../api/activity/model/OrderedCollectionPage.java | 8 +-- .../juick/server/api/activity/model/Person.java | 6 +- .../api/activity/model/activities/Accept.java | 6 ++ .../api/activity/model/activities/Create.java | 6 ++ .../api/activity/model/activities/Delete.java | 6 ++ .../api/activity/model/activities/Follow.java | 6 ++ .../server/api/activity/model/activities/Undo.java | 6 ++ .../juick/server/configuration/SecurityConfig.java | 2 +- .../com/juick/service/activities/FollowEvent.java | 21 ++++++ .../java/com/juick/server/tests/ServerTests.java | 39 +++++++--- juick-server/src/test/resources/follow.json | 42 +++++++++++ juick-server/src/test/resources/person.json | 54 ++++++++++++++ juick-server/src/test/resources/undo.json | 47 +++++++++++++ 28 files changed, 551 insertions(+), 127 deletions(-) create mode 100644 juick-server/src/main/java/com/juick/server/ActivityPubManager.java create mode 100644 juick-server/src/main/java/com/juick/server/SignatureManager.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/Activity.java delete mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/ActivityObject.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/Context.java delete mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/Create.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/activities/Accept.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/activities/Create.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/activities/Delete.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/activities/Follow.java create mode 100644 juick-server/src/main/java/com/juick/server/api/activity/model/activities/Undo.java create mode 100644 juick-server/src/main/java/com/juick/service/activities/FollowEvent.java create mode 100644 juick-server/src/test/resources/follow.json create mode 100644 juick-server/src/test/resources/person.json create mode 100644 juick-server/src/test/resources/undo.json diff --git a/build.gradle b/build.gradle index 7de01cdf..e8a7f762 100644 --- a/build.gradle +++ b/build.gradle @@ -49,7 +49,7 @@ class GenKey extends DefaultTask { String keystore @TaskAction def generate() { - ant.genkey(alias:"1", keystore:keystore.toString(), storepass:"secret", dname:"CN=localhost", keysize:2048) + ant.genkey(alias:"1", keystore:keystore.toString(), storetype:"PKCS12", keyalg:"RSA", storepass:"secret", dname:"CN=localhost", keysize:2048) } } diff --git a/juick-server/build.gradle b/juick-server/build.gradle index c412f0f8..f1b8f2a7 100644 --- a/juick-server/build.gradle +++ b/juick-server/build.gradle @@ -103,6 +103,7 @@ dependencies { compile 'io.pebbletemplates:pebble-spring5:3.0.5' compile 'com.atlassian.commonmark:commonmark:0.11.0' compile 'com.atlassian.commonmark:commonmark-ext-autolink:0.11.0' + compile 'org.tomitribe:tomitribe-http-signatures:1.1' testCompile ("org.springframework.boot:spring-boot-starter-test") testCompile ('net.sourceforge.htmlunit:htmlunit:2.33') 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 { + 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 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 request = new HttpEntity<>(Context.build(data), requestHeaders); + //boolean valid = verifySignature(Signature.fromString(requestHeaders.getFirst("Signature")), + // keystoreManager.getPublicKey(), "POST", inbox.getPath(), headers); + ResponseEntity 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 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 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 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 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 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 context; + + private String id; + + private Instant published; + + + private List to; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return getClass().getSimpleName(); + } + + @JsonProperty("@context") + public List 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 getTo() { + return to; + } + + public void setTo(List 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 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 getTo() { - return to; - } - - public void setTo(List 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 orderedItems; + private List orderedItems; public String getNext() { return next; @@ -22,11 +22,11 @@ public class OrderedCollectionPage extends ActivityObject { this.next = next; } - public List getOrderedItems() { + public List getOrderedItems() { return orderedItems; } - public void setOrderedItems(List orderedItems) { + public void setOrderedItems(List 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 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 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 -- cgit v1.2.3