/* * 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 <http://www.gnu.org/licenses/>. */ package com.juick; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; 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.HttpBadRequestException; import com.juick.util.HttpNotFoundException; 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.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.core.convert.ConversionService; import org.springframework.http.MediaType; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; 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.NoSuchAlgorithmException; import java.time.Instant; import java.util.*; 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; @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("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.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 (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<String> 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<String> 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.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC)); 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) { var to = (Actor) follower; try { activityPubService.post(from, to, activity); } catch (Exception e) { logger.warn("Delivery to {} failed: {}", to, 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<String> 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<String> cc = new ArrayList<>(note.getCc()); cc.add(replier); note.setCc(cc); } subscribers.addAll(note.getCc()); subscribers.forEach(acct -> { if (!acct.equals(profileUriBuilder.followersUri(user))) { var context = activityPubService.get(URI.create(acct)); context.ifPresentOrElse((follower) -> { if (follower instanceof Actor) { var to = (Actor) follower; 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 (IOException | NoSuchAlgorithmException e) { logger.warn("Delivery to {} failed: {}", to, 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<String, Object> 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 { note.setTo(Collections.singletonList(Context.ACTIVITYSTREAMS_PUBLIC)); note.setCc(Collections.singletonList(profileUriBuilder.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(profileUriBuilder.tagUri(t), t.getName())) .collect(Collectors.toList())); if (msg.getReplyToUri() != null && msg.getReplyToUri().toASCIIString().length() > 0) { Optional<Context> noteContext = activityPubService.get(msg.getReplyToUri()); if (noteContext.isPresent()) { Note activity = (Note) noteContext.get(); Optional<Context> 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<String> 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.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(); } } }