aboutsummaryrefslogblamecommitdiff
path: root/src/main/java/com/juick/ActivityPubManager.java
blob: 566cc1fdbfcd91dac5ebb801b9228ed6a30e63aa (plain) (tree)
1
2
  
                                 













                                                                           
                  
 
                                                          

                                
                                            
                                         
                                          
                                      


                                                        
                                            
                                   
                                                    
                                                     
                                                            
                                                 
                                                
                                                     
                                                         
                                            
                               
                                                          
                                          
                                                  
                                                         
 





                                              
                         

                                   
                                                                                   
                                                                                
           

                                                  
           
                                        
                                            
                                      
                                        
                                        
                            
 
             
                                                                      
                                                                   


                                                                    
                                                                            
                                                                                                                   


                                                       
                                                              
                                                                          
                                                                                                 
                                                                                       
                                                        
         

             
                                                        
                                      






                                                                  
                                                        
                                                         


                                          

                                                              
                                  
                                                                
                                                          
                 
                                                                                                



                                                         
                                                                         
                                                              
                                                                                       
                                                        
                                                     



             

                                                                                                        
                                          
                                                                    
                                
                                                                                                     






                                                                                                        
                                          
                                                                    
                                
                                                                                              



             
                                                       
                                            
                                    
                                                                



                                               

                                                                     

             



                                                                   
                                                                                                     
         
 
                                                                                
                                                                   
                                                   
                                                   
                         
                                                                    
                                           
                                                                                             












                                                                                                  

                                                               
                                                           
                                                                






                                                                                
     
 
                                                        
                   
                                  
                                  









                                                                                             
                                                                  
                                                                       
                                                       
                                                       




                                                                 
                                                                    
                                                                                                   
                                                                                                 





                                                                                                      
                     

                                                                  
             

           













                                                                                        
                                       
                                                      
                                                       
                                                                         
                                        
                                                                       
                    
                                                                                                
             
                                     
                                                                                            
                
                                                                                                     
                                       



                                                                                      
         
                                            
                                                              


                                                                                  
                                                                      
         
                                                                                                           
                                                                                              
                                                                                        
                                                         
                                                                                                                 
                                                
                                                               
                                                                                                    
                                                                       
                                                               
                 
                                               
                                                                                                                
         
                                                          









                                                                                                    
             
                                                         

                                           
                                                                          
         


                    


                                                       
                                                          
                                           
                                                                     
                                                             
                                                                                  
                                 

                                                                                       
     
 
                                                                     
                                                  

                                                                          
                                        




                                                         
                                                      
                                                
         
     
                                                                              
                                              














                                                                                                        
 
/*
 * 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("{} 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<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.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<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))
                    && !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<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 {
            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<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.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();
        }
    }
}