/*
* 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 java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URI;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import com.juick.model.Message;
import com.juick.model.Reaction;
import com.juick.model.Tag;
import com.juick.model.User;
import com.juick.service.MessagesService;
import com.juick.service.SocialService;
import com.juick.service.activities.ActivityListener;
import com.juick.service.activities.AnnounceEvent;
import com.juick.service.activities.DeleteMessageEvent;
import com.juick.service.activities.DeleteUserEvent;
import com.juick.service.activities.FollowEvent;
import com.juick.service.activities.UndoAnnounceEvent;
import com.juick.service.activities.UndoFollowEvent;
import com.juick.service.activities.UpdateEvent;
import com.juick.service.activities.UpdateUserEvent;
import com.juick.service.component.NotificationListener;
import com.juick.service.component.PingEvent;
import com.juick.service.component.SystemEvent;
import com.juick.util.HttpBadRequestException;
import com.juick.util.HttpUtils;
import com.juick.util.MessageUtils;
import com.juick.util.formatters.PlainTextFormatter;
import com.juick.www.api.SystemActivity.ActivityType;
import com.juick.www.api.activity.model.Context;
import com.juick.www.api.activity.model.activities.Accept;
import com.juick.www.api.activity.model.activities.Announce;
import com.juick.www.api.activity.model.activities.Create;
import com.juick.www.api.activity.model.activities.Delete;
import com.juick.www.api.activity.model.activities.Update;
import com.juick.www.api.activity.model.objects.Actor;
import com.juick.www.api.activity.model.objects.Hashtag;
import com.juick.www.api.activity.model.objects.Image;
import com.juick.www.api.activity.model.objects.Mention;
import com.juick.www.api.activity.model.objects.Note;
import com.juick.www.api.activity.model.objects.Person;
import com.mitchellbosecke.pebble.PebbleEngine;
import com.mitchellbosecke.pebble.template.PebbleTemplate;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
public class ActivityPubManager implements ActivityListener, NotificationListener {
private static final Logger logger = LoggerFactory.getLogger("ActivityPub");
@Inject
private SignatureManager signatureManager;
@Inject
private SocialService socialService;
@Inject
private MessagesService messagesService;
@Inject
private PebbleEngine pebbleEngine;
@Value("${ap_base_uri:http://localhost:8080/}")
private String baseUri;
@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 = (Actor) signatureManager.getContext(URI.create(acct)).get();
Actor follower = (Actor) signatureManager.getContext(URI.create(followEvent.getRequest().getActor())).get();
Accept accept = new Accept();
accept.setActor(me.getId());
accept.setObject(followEvent.getRequest());
try {
signatureManager.post(me, follower, accept);
socialService.addFollower(followedUser, follower.getId());
logger.info("Follower added for {}", followedUser.getName());
} catch (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.info("Deleting {} from followers", acct);
socialService.removeAccount(acct);
}
@Override
public void deleteMessageEvent(DeleteMessageEvent event) {
Message msg = event.getMessage();
User user = msg.getUser();
String userUri = personUri(user);
Note note = makeNote(msg);
Actor me = (Actor) signatureManager.getContext(URI.create(userUri)).get();
socialService.getFollowers(user).forEach(acct -> {
try {
Actor follower = (Actor) signatureManager.getContext(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());
signatureManager.post(me, follower, delete);
} catch (NoSuchElementException e) {
logger.warn("Unverified actor: {}", userUri);
} catch (IOException | NoSuchAlgorithmException e) {
logger.warn("activitypub exception", e);
}
});
}
@Override
public void processAnnounceEvent(AnnounceEvent event) {
UriComponents uriComponents = UriComponentsBuilder.fromUriString(event.getMessageUri()).build();
List segments = uriComponents.getPathSegments();
if (segments.get(0).equals("n")) {
String[] ids = segments.get(1).split("-", 2);
if (ids.length == 2 && Integer.parseInt(ids[1]) == 0) {
// only messages
logger.info("{} recommends {}", event.getActorUri(), Integer.valueOf(ids[0]));
messagesService.likeMessage(Integer.parseInt(ids[0]), 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")) {
String[] ids = segments.get(1).split("-", 2);
if (ids.length == 2 && Integer.parseInt(ids[1]) == 0) {
// only messages
logger.info("{} stop recommending {}", event.getActorUri(), Integer.valueOf(ids[0]));
messagesService.likeMessage(Integer.parseInt(ids[0]), 0, null, event.getActorUri());
}
}
}
@Override
public void processUpdateEvent(UpdateEvent event) {
String objectUri = event.getMessageUri();
User user = event.getUser();
String userUri = personUri(user);
Actor me = (Actor) signatureManager.getContext(URI.create(userUri)).get();
socialService.getFollowers(user).forEach(acct -> {
try {
Actor follower = (Actor) signatureManager.getContext(URI.create(acct)).orElseThrow();
Update update = new Update();
update.setId(objectUri + "#update");
update.setActor(me.getId());
update.setObject(new Context(objectUri));
logger.info("Update to follower {}", follower.getId());
signatureManager.post(me, follower, update);
} catch (NoSuchElementException e) {
logger.warn("Person not verified: {}", acct);
} catch (IOException | NoSuchAlgorithmException e) {
logger.warn("activitypub exception", e);
}
});
}
@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());
}
}
@Override
public void processUpdateUserEvent(UpdateUserEvent event) {
User user = event.getUser();
String userUri = personUri(user);
Actor me = (Actor) signatureManager.getContext(URI.create(userUri)).get();
socialService.getFollowers(user).forEach(acct -> {
try {
Actor follower = (Actor) signatureManager.getContext(URI.create(acct)).orElseThrow();
Update update = new Update();
update.setId(userUri + "#update");
update.setActor(me.getId());
update.setObject(new Context(userUri));
logger.info("Update to follower {}", follower.getId());
signatureManager.post(me, follower, update);
} catch (NoSuchElementException e) {
logger.warn("Person not verified: {}", acct);
} catch (IOException | NoSuchAlgorithmException e) {
logger.warn("activitypub exception", e);
}
});
}
private void processMessage(Message msg) {
if (MessageUtils.isPM(msg) || msg.isService()) {
return;
}
User user = msg.getUser();
String userUri = personUri(user);
Note note = makeNote(msg);
Actor me = (Actor) signatureManager.getContext(URI.create(userUri)).get();
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 -> {
Optional context = signatureManager.getContext(URI.create(acct));
if (context.isPresent() && context.get() instanceof Actor) {
Actor follower = (Actor)context.get();
Create create = new Create();
create.setId(note.getId());
create.setActor(me.getId());
create.setPublished(note.getPublished());
create.setObject(note);
try {
signatureManager.post(me, follower, create);
} catch (IOException | NoSuchAlgorithmException e) {
logger.warn("activitypub exception", e);
}
}
});
}
public String inboxUri() {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath("/api/inbox").toUriString();
}
public String outboxUri(User user) {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath(String.format("/u/%s/blog/toc", user.getName())).toUriString();
}
public String personUri(User user) {
if (user.getUri().toString().length() > 0) {
return user.getUri().toASCIIString();
}
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath(String.format("/u/%s", user.getName())).toUriString();
}
public String personWebUri(User user) {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath(String.format("/%s/", user.getName())).toUriString();
}
public String followersUri(User user) {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath(String.format("/u/%s/followers/toc", user.getName())).toUriString();
}
public String followingUri(User user) {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath(String.format("/u/%s/following/toc", user.getName())).toUriString();
}
public String messageUri(Message msg) {
return messageUri(msg.getMid(), msg.getRid());
}
public String messageUri(int mid, int rid) {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
uri.replacePath(String.format("/n/%d-%d", mid, rid));
return uri.toUriString();
}
public String tagUri(Tag tag) {
UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
return uri.replacePath(String.format("/t/%s", tag.getName())).toUriString();
}
public String postId(String messageUri) {
UriComponents uri = UriComponentsBuilder.fromUriString(messageUri).build();
return uri.getPath().substring(uri.getPath().lastIndexOf('/') + 1).replace("-", "/");
}
public Note makeNote(Message msg) {
Note note = new Note();
note.setId(messageUri(msg));
note.setUrl(PlainTextFormatter.formatUrl(msg));
note.setAttributedTo(personUri(msg.getUser()));
if (MessageUtils.isReply(msg)) {
if (msg.getReplyToUri().toASCIIString().length() > 0) {
note.setInReplyTo(msg.getReplyToUri().toASCIIString());
} else {
note.setInReplyTo(messageUri(msg.getMid(), msg.getReplyto()));
}
}
if (MessageUtils.isPM(msg)) {
note.setTo(Collections.singletonList(personUri(msg.getTo())));
} else {
note.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC));
note.setCc(Collections.singletonList(followersUri(msg.getUser())));
}
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(tagUri(t), t.getName())).collect(Collectors.toList()));
if (msg.getReplyToUri() != null && msg.getReplyToUri().toASCIIString().length() > 0) {
Optional noteContext = signatureManager.getContext(msg.getReplyToUri());
if (noteContext.isPresent()) {
Note activity = (Note) noteContext.get();
Optional personContext = signatureManager.getContext(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(personWebUri(msg.getTo()), msg.getTo().getName()));
}
MessageUtils.getGlobalMentions(msg).forEach(m -> {
// @user@server.tld -> user@server.tld
Optional personContext = signatureManager.discoverPerson(m.substring(1));
if (personContext.isPresent()) {
Person person = (Person) personContext.get();
note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername()));
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 {
PebbleTemplate noteTemplate = pebbleEngine.getTemplate("layouts/note");
Map context = new HashMap<>();
context.put("msg", msg);
context.put("baseUri", baseUri);
try {
Writer writer = new StringWriter();
noteTemplate.evaluate(writer, context);
note.setContent(writer.toString());
} catch (IOException e) {
logger.warn("template not rendered, falling back");
note.setContent(MessageUtils.formatMessage(StringUtils.defaultString(msg.getText())));
}
}
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(personUri(user));
announce.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC));
announce.setObject(note);
signatureManager.getContext(URI.create(announce.getActor())).ifPresentOrElse((ctx) -> {
if (ctx instanceof Actor) {
socialService.getFollowers(user).forEach(acct -> {
var follower = signatureManager.getContext(URI.create(acct));
follower.ifPresentOrElse((person) -> {
if (person instanceof Actor) {
try {
logger.info("{} announcing {} to {}", user.getName(), message.getMid(), acct);
signatureManager.post((Actor) ctx, (Actor) person, announce);
} catch (IOException | NoSuchAlgorithmException e) {
logger.warn("activitypub exception", e);
}
} else {
logger.warn("Unhandled context: {}", acct);
}
}, () -> logger.warn("Follower not found: {}", acct));
});
} else {
logger.warn("Unhandled context: {}", announce.getActor());
}
}, () -> {
logger.warn("Context not found: {}", announce.getActor());
});
}
public User actorToUser(URI uri) throws HttpBadRequestException {
Actor person = (Actor) signatureManager.getContext(uri).orElseThrow(HttpBadRequestException::new);
User user = new User();
user.setUri(URI.create(person.getId()));
user.setName(person.getPreferredUsername());
if (person.getIcon() != null) {
user.setAvatar(person.getIcon().getUrl());
}
return user;
}
}