/*
* Copyright (C) 2008-2020, Juick
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package com.juick;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juick.model.AnonymousUser;
import com.juick.model.Message;
import com.juick.model.Reaction;
import com.juick.model.User;
import com.juick.service.ActivityPubService;
import com.juick.service.MessagesService;
import com.juick.service.SocialService;
import com.juick.service.WebfingerService;
import com.juick.service.activities.*;
import com.juick.service.component.NotificationListener;
import com.juick.service.component.PingEvent;
import com.juick.service.component.SystemEvent;
import com.juick.util.*;
import com.juick.util.formatters.PlainTextFormatter;
import com.juick.www.api.SystemActivity.ActivityType;
import com.juick.www.api.activity.helpers.ProfileUriBuilder;
import com.juick.www.api.activity.model.Activity;
import com.juick.www.api.activity.model.Context;
import com.juick.www.api.activity.model.activities.*;
import com.juick.www.api.activity.model.objects.*;
import io.pebbletemplates.pebble.PebbleEngine;
import io.pebbletemplates.pebble.template.PebbleTemplate;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.convert.ConversionService;
import org.springframework.http.MediaType;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import org.tomitribe.auth.signatures.MissingRequiredHeaderException;
import org.tomitribe.auth.signatures.Signature;
import org.tomitribe.auth.signatures.Verifier;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URI;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.security.SignatureException;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
public class ActivityPubManager implements ActivityListener, NotificationListener {
private static final Logger logger = LoggerFactory.getLogger("ActivityPub");
@Inject
private ActivityPubService activityPubService;
@Inject
private WebfingerService webfingerService;
@Inject
private SocialService socialService;
@Inject
private MessagesService messagesService;
@Inject
private PebbleEngine pebbleEngine;
@Inject
ProfileUriBuilder profileUriBuilder;
@Inject
ConversionService conversionService;
@Inject
ObjectMapper jsonMapper;
@Inject
private ApplicationEventPublisher applicationEventPublisher;
@Inject
private KeystoreManager keystoreManager;
@Override
public void processFollowEvent(@Nonnull FollowEvent followEvent) {
String acct = followEvent.getRequest().getObject().getId();
logger.info("received follower request to {}", acct);
User followedUser = socialService.getUserByAccountUri(acct);
if (!followedUser.isAnonymous()) {
// automatically accept follower requests
Actor me = conversionService.convert(followedUser, Actor.class);
Actor follower = (Actor) activityPubService.get(URI.create(followEvent.getRequest().getActor())).get();
Accept accept = new Accept();
accept.setActor(me.getId());
accept.setObject(followEvent.getRequest());
try {
activityPubService.post(me, follower, accept);
socialService.addFollower(followedUser, follower.getId());
logger.info("{} started to follow {}", follower.getId(), followedUser.getName());
} catch (InterruptedException | IOException | NoSuchAlgorithmException e) {
logger.info("activitypub exception", e);
}
}
}
@Override
public void undoFollowEvent(UndoFollowEvent event) {
String actor = event.getActor();
String me = event.getObject();
logger.info("{} stopping to follow {}", actor, me);
User followedUser = socialService.getUserByAccountUri(me);
if (!followedUser.isAnonymous()) {
socialService.removeFollower(followedUser, actor);
}
}
@Override
public void deleteUserEvent(DeleteUserEvent event) {
String acct = event.getUserUri();
logger.debug("Deleting {} from followers", acct);
socialService.removeAccount(acct);
}
@Override
public void deleteMessageEvent(DeleteMessageEvent event) {
Message msg = event.getMessage();
User user = msg.getUser();
Note note = makeNote(msg);
Actor me = conversionService.convert(user, Actor.class);
socialService.getFollowers(user).forEach(acct -> {
try {
Actor follower = (Actor) activityPubService.get(URI.create(acct)).orElseThrow();
Delete delete = new Delete();
delete.setId(note.getId());
delete.setActor(me.getId());
delete.setPublished(note.getPublished());
delete.setObject(note);
logger.info("Deletion to follower {}", follower.getId());
activityPubService.post(me, follower, delete);
} catch (InterruptedException | IOException | NoSuchAlgorithmException e) {
logger.warn("activitypub exception", e);
} catch (NoSuchElementException ex) {
logger.warn("Can not find {}", acct);
}
});
}
@Override
public void processAnnounceEvent(AnnounceEvent event) {
UriComponents uriComponents = UriComponentsBuilder.fromUriString(event.getMessageUri()).build();
List segments = uriComponents.getPathSegments();
if (segments.get(0).equals("n")) {
var message = findMessage(segments.get(1));
if (message != null && !MessageUtils.isReply(message)) {
// only messages
logger.info("{} recommends {}", event.getActorUri(), message.getMid());
messagesService.likeMessage(message.getMid(), 0, Reaction.LIKE, event.getActorUri());
}
}
}
@Override
public void undoAnnounceEvent(UndoAnnounceEvent event) {
UriComponents uriComponents = UriComponentsBuilder.fromUriString(event.getMessageUri()).build();
List segments = uriComponents.getPathSegments();
if (segments.get(0).equals("n")) {
var message = findMessage(segments.get(1));
if (message != null && !MessageUtils.isReply(message)) {
// only messages
logger.info("{} stop recommending {}", event.getActorUri(), message.getMid());
messagesService.likeMessage(message.getMid(), 0, null, event.getActorUri());
}
}
}
@Override
public void processUpdateEvent(UpdateEvent event) {
Message object = event.getMessage();
User user = event.getUser();
Actor me = conversionService.convert(user, Actor.class);
Update update = new Update();
var note = makeNote(object);
update.setId(note.getId() + "#update");
update.setActor(me.getId());
update.setObject(note);
update.setPublished(Instant.now());
logger.info("{} sends note update to followers", me.getId());
activityToFollowers(user, me, update);
}
@Override
public void processSystemEvent(SystemEvent systemEvent) {
ActivityType type = systemEvent.getActivity().getType();
if (type.equals(ActivityType.message)) {
processMessage(systemEvent.getActivity().getMessage());
} else if (type.equals(ActivityType.like)) {
processLike(systemEvent.getActivity().getFrom(), systemEvent.getActivity().getMessage());
}
}
private void activityToFollowers(User user, Actor from, Activity activity) {
socialService.getFollowers(user).forEach(acct -> {
var context = activityPubService.get(URI.create(acct));
context.ifPresentOrElse((follower) -> {
if (follower instanceof Actor to) {
try {
activityPubService.post(from, to, activity);
} catch (Exception e) {
logger.warn("Delivery to {} failed: {}", to.getId(), e.getMessage());
}
} else {
try {
logger.warn("Unknown actor: {}", jsonMapper.writeValueAsString(follower));
} catch (JsonProcessingException e) {
logger.warn("Invalid JSON: {}", acct);
}
}
}, () -> {
logger.warn("Context not verified: {}", acct);
});
});
}
@Override
public void processUpdateUserEvent(UpdateUserEvent event) {
User user = event.getUser();
String userUri = profileUriBuilder.personUri(user);
Actor me = conversionService.convert(user, Actor.class);
Update update = new Update();
update.setId(userUri + "#update");
update.setActor(me.getId());
update.setObject(me);
update.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC));
update.setPublished(Instant.now());
logger.info("{} sends profile update to followers", me.getId());
activityToFollowers(user, me, update);
}
private void processMessage(Message msg) {
if (MessageUtils.isPM(msg) || msg.isService()) {
return;
}
User user = msg.getUser();
Note note = makeNote(msg);
var me = conversionService.convert(user, Actor.class);
Set subscribers = new HashSet<>(socialService.getFollowers(user));
if (MessageUtils.isReply(msg) && msg.getTo().getUri().toASCIIString().length() > 0) {
String replier = msg.getTo().getUri().toASCIIString();
subscribers.add(replier);
List cc = new ArrayList<>(note.getCc());
cc.add(replier);
note.setCc(cc);
}
subscribers.addAll(note.getCc());
subscribers.forEach(acct -> {
if (!acct.equals(profileUriBuilder.followersUri(user))
&& !acct.equals(me.getId())) {
var context = activityPubService.get(URI.create(acct));
context.ifPresentOrElse((follower) -> {
if (follower instanceof Actor to) {
Create create = new Create();
create.setId(note.getId());
create.setActor(me.getId());
create.setPublished(note.getPublished());
create.setObject(note);
try {
activityPubService.post(me, to, create);
} catch (InterruptedException | IOException | NoSuchAlgorithmException e) {
logger.warn("Delivery to {} failed: {}", to.getId(), e.getMessage());
}
} else {
try {
logger.warn("Unknown actor: {}", jsonMapper.writeValueAsString(follower));
} catch (JsonProcessingException e) {
logger.warn("Invalid JSON: {}", acct);
}
}
}, () -> {
logger.warn("Context not verified: {}", acct);
});
}
});
}
public String htmlLayout(Message msg, String baseUri) {
PebbleTemplate noteTemplate = pebbleEngine.getTemplate("layouts/note");
Map context = new HashMap<>();
context.put("msg", msg);
context.put("baseUri", profileUriBuilder.baseUri());
try {
Writer writer = new StringWriter();
noteTemplate.evaluate(writer, context);
return writer.toString();
} catch (IOException e) {
logger.warn("template not rendered, falling back");
return MessageUtils.formatMessage(StringUtils.defaultString(msg.getText()));
}
}
public Note makeNote(Message msg) {
Note note = new Note();
note.setId(profileUriBuilder.messageUri(msg));
note.setUrl(PlainTextFormatter.formatUrl(msg));
note.setAttributedTo(profileUriBuilder.personUri(msg.getUser()));
if (MessageUtils.isReply(msg)) {
if (msg.getReplyToUri().toASCIIString().length() > 0) {
note.setInReplyTo(msg.getReplyToUri().toASCIIString());
} else {
note.setInReplyTo(profileUriBuilder.messageUri(msg.getMid(), msg.getReplyto()));
}
}
if (MessageUtils.isPM(msg)) {
note.setTo(Collections.singletonList(profileUriBuilder.personUri(msg.getTo())));
} else {
var followers = Collections.singletonList(profileUriBuilder.followersUri(msg.getUser()));
if (!msg.isFriendsOnly()) {
note.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC));
note.setCc(followers);
} else {
note.setTo(followers);
}
}
note.setPublished(msg.getCreated());
if (StringUtils.isNotBlank(msg.getAttachmentType())) {
Image attachment = new Image();
attachment.setId(msg.getAttachment().getMedium().getUrl());
attachment.setUrl(msg.getAttachment().getMedium().getUrl());
attachment.setMediaType(HttpUtils.mediaType(msg.getAttachmentType()));
note.setAttachment(Collections.singletonList(attachment));
}
note.setTags(msg.getTags().stream().map(t -> new Hashtag(profileUriBuilder.tagUri(t), t.getName()))
.collect(Collectors.toList()));
if (msg.getReplyToUri() != null && msg.getReplyToUri().toASCIIString().length() > 0) {
Optional noteContext = activityPubService.get(msg.getReplyToUri());
if (noteContext.isPresent()) {
Note activity = (Note) noteContext.get();
Optional personContext = activityPubService.get(URI.create(activity.getAttributedTo()));
if (personContext.isPresent()) {
Actor person = (Actor) personContext.get();
note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername()));
msg.getTo().setName(person.getPreferredUsername());
note.setInReplyTo(activity.getInReplyTo());
}
}
} else if (MessageUtils.isReply(msg)) {
note.getTags().add(new Mention(profileUriBuilder.personWebUri(msg.getTo()), msg.getTo().getName()));
}
MessageUtils.getGlobalMentions(msg).forEach(m -> {
// @user@server.tld -> user@server.tld
var personURI = webfingerService.discoverAccountURI(m.substring(1),
MediaType.valueOf(Context.ACTIVITY_MEDIA_TYPE));
if (!personURI.toASCIIString().isEmpty()) {
var personContext = activityPubService.get(personURI);
if (personContext.isPresent()) {
Actor person = (Actor) personContext.get();
note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername()));
List cc = new ArrayList<>(note.getCc());
cc.add(person.getId());
note.setCc(cc);
}
}
});
note.setSensitive(MessageUtils.isSensitive(msg));
if (msg.isHtml()) {
note.setContent(msg.getText());
} else {
note.setContent(htmlLayout(msg, profileUriBuilder.baseUri()));
}
return note;
}
@Override
public void processPingEvent(PingEvent pingEvent) {
}
private void processLike(User user, Message message) {
Note note = makeNote(message);
Announce announce = new Announce();
announce.setId(note.getId() + "#announce-" + user.getName());
announce.setActor(profileUriBuilder.personUri(user));
announce.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC));
announce.setObject(note);
var me = conversionService.convert(user, Actor.class);
logger.info("{} announcing {} to followers", user.getName(), message.getMid());
activityToFollowers(user, me, announce);
}
public User actorToUser(URI uri) throws HttpBadRequestException {
var context = activityPubService.get(uri);
if (context.isPresent() && context.get() instanceof Actor actor) {
User user = new User();
user.setUri(URI.create(actor.getId()));
user.setUrl(actor.getUrl());
user.setName(actor.getPreferredUsername());
if (actor.getIcon() != null) {
user.setAvatar(actor.getIcon().getUrl());
}
return user;
} else {
logger.warn("Unhandled context: {}", uri);
throw new HttpBadRequestException();
}
}
public Message findMessage(String statusId) throws HttpNotFoundException {
String[] ids = statusId.split("-", 2);
try {
if (ids.length == 2) {
return findMessage(Integer.parseInt(ids[0]), Integer.parseInt(ids[1]));
}
} catch (Exception e) {
logger.warn("{}: message not found, error: {}", statusId, e.getMessage());
}
throw new HttpNotFoundException();
}
public Message findMessage(int mid, int rid) throws HttpNotFoundException {
try {
return rid > 0 ? messagesService.getReply(mid, rid) : messagesService.getMessage(mid).get();
} catch (NoSuchElementException e) {
throw new HttpNotFoundException();
}
}
public User verifyActor(String method, String path, Map headers) {
String signatureString = headers.get("signature");
if (StringUtils.isNotEmpty(signatureString)) {
Signature signature = Signature.fromString(signatureString);
var keyId = UriComponentsBuilder.fromUriString(signature.getKeyId()).fragment(null).build().toUri();
var user = activityPubService.getUserByAccountUri(keyId.toASCIIString());
Key key = null;
Actor actor = null;
if (!user.isAnonymous()) {
// local user
key = keystoreManager.getPublicKey();
} else {
var context = activityPubService.get(keyId);
if (context.isPresent()) {
actor = (Actor) context.get();
key = KeystoreManager.publicKeyOf(actor);
}
}
if (key != null) {
try {
Verifier verifier = new Verifier(key, signature);
boolean result = verifier.verify(method.toLowerCase(), path, headers);
if (result) {
if (!user.isAnonymous()) {
return user;
} else {
if (actor != null) {
User person = new User();
person.setUri(URI.create(actor.getId()));
if (actor.isSuspended()) {
logger.info("{} is suspended, deleting", actor.getId());
applicationEventPublisher
.publishEvent(new DeleteUserEvent(this, actor.getId()));
}
return person;
}
}
}
} catch (Exception e) {
logger.warn("Verification error for {}: {}", signature.getKeyId(), e.getMessage());
}
}
}
return AnonymousUser.INSTANCE;
}
}