/*
* Copyright (C) 2008-2019, 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.server;
import com.juick.Message;
import com.juick.Reaction;
import com.juick.User;
import com.juick.formatters.PlainTextFormatter;
import com.juick.server.api.SystemActivity.ActivityType;
import com.juick.server.api.activity.model.Context;
import com.juick.server.api.activity.model.activities.*;
import com.juick.server.api.activity.model.objects.Hashtag;
import com.juick.server.api.activity.model.objects.Image;
import com.juick.server.api.activity.model.objects.Mention;
import com.juick.server.api.activity.model.objects.Note;
import com.juick.server.api.activity.model.objects.Person;
import com.juick.server.util.HttpBadRequestException;
import com.juick.server.util.HttpUtils;
import com.juick.service.MessagesService;
import com.juick.service.SocialService;
import com.juick.service.UserService;
import com.juick.service.activities.*;
import com.juick.service.component.*;
import com.juick.util.MessageUtils;
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.stereotype.Component;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import javax.annotation.Nonnull;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URI;
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.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class ActivityPubManager implements ActivityListener, NotificationListener {
private static final Logger logger = LoggerFactory.getLogger("ActivityPub");
@Inject
private SignatureManager signatureManager;
@Inject
private SocialService socialService;
@Inject
private UserService userService;
@Inject
private MessagesService messagesService;
@Inject
private PebbleEngine pebbleEngine;
@Value("${ap_base_uri:http://localhost:8080/}")
private String baseUri;
@Value("${service_user:juick}")
private String serviceUsername;
private User serviceUser;
@PostConstruct
public void init() {
serviceUser = userService.getUserByName(serviceUsername);
}
@Override
public void processFollowEvent(@Nonnull FollowEvent followEvent) {
String acct = (String)followEvent.getRequest().getObject();
logger.info("received follower request to {}", acct);
User followedUser = socialService.getUserByAccountUri(acct);
if (!followedUser.isAnonymous()) {
// automatically accept follower requests
Person me = (Person) signatureManager.getContext(URI.create(acct)).get();
Person follower = (Person) 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 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);
Person me = (Person) signatureManager.getContext(URI.create(userUri)).get();
socialService.getFollowers(user).forEach(acct -> {
Person follower = (Person) signatureManager.getContext(URI.create(acct)).get();
Delete delete = new Delete();
delete.setId(note.getId());
delete.setActor(me.getId());
delete.setPublished(note.getPublished());
delete.setObject(note);
try {
logger.info("Deletion to follower {}", follower.getId());
signatureManager.post(me, follower, delete);
} catch (IOException 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);
Person me = (Person) signatureManager.getContext(URI.create(userUri)).get();
socialService.getFollowers(user).forEach(acct -> {
Person follower = (Person) signatureManager.getContext(URI.create(acct)).get();
Update update = new Update();
update.setId(objectUri + "#update");
update.setActor(me.getId());
update.setObject(objectUri);
try {
logger.info("Update to follower {}", follower.getId());
signatureManager.post(me, follower, update);
} catch (IOException 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)) {
if (systemEvent.getActivity().getFrom().equals(serviceUser)) {
processTop(systemEvent.getActivity().getMessage());
}
}
}
private void processMessage(Message msg) {
if (MessageUtils.isPM(msg) || msg.isService()) {
return;
}
User user = msg.getUser();
String userUri = personUri(user);
Note note = makeNote(msg);
Person me = (Person) 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 Person) {
Person follower = (Person)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 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(com.juick.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()) {
Person person = (Person) 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);
}
});
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 processTop(Message message) {
Note note = makeNote(message);
Announce announce = new Announce();
announce.setId(note.getId() + "#top");
announce.setActor(personUri(serviceUser));
announce.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC));
announce.setObject(note);
Person me = (Person) signatureManager.getContext(URI.create(announce.getActor())).get();
socialService.getFollowers(serviceUser).forEach(acct -> {
var follower = signatureManager.getContext(URI.create(acct));
follower.ifPresentOrElse((person) -> {
try {
logger.info("Announcing top: {}", message.getMid());
signatureManager.post(me, (Person)person, announce);
} catch (IOException e) {
logger.warn("activitypub exception", e);
}
}, () -> logger.warn("Follower not found: {}", acct));
});
}
public User personToUser(URI uri) throws HttpBadRequestException {
Person person = (Person) 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;
}
}