aboutsummaryrefslogblamecommitdiff
path: root/src/main/java/com/juick/www/api/activity/Profile.java
blob: f37ef6ffd83786dfe9d2889b2089de02933ceaaf (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.www.api.activity;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juick.model.Message;
import com.juick.model.User;
import com.juick.util.formatters.PlainTextFormatter;
import com.juick.model.CommandResult;
import com.juick.ActivityPubManager;
import com.juick.CommandsManager;
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.Announce;
import com.juick.www.api.activity.model.activities.Block;
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.Flag;
import com.juick.www.api.activity.model.activities.Follow;
import com.juick.www.api.activity.model.activities.Like;
import com.juick.www.api.activity.model.activities.Undo;
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.Note;
import com.juick.www.api.activity.model.objects.OrderedCollection;
import com.juick.www.api.activity.model.objects.OrderedCollectionPage;
import com.juick.www.api.activity.model.objects.Person;
import com.juick.util.HttpBadRequestException;
import com.juick.util.HttpNotFoundException;
import com.juick.service.MessagesService;
import com.juick.service.UserService;
import com.juick.service.activities.AnnounceEvent;
import com.juick.service.activities.DirectMessageEvent;
import com.juick.service.activities.FollowEvent;
import com.juick.service.activities.UndoAnnounceEvent;
import com.juick.service.activities.UndoFollowEvent;
import com.overzealous.remark.Remark;
import io.swagger.v3.oas.annotations.Parameter;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.convert.ConversionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;

import javax.inject.Inject;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.stream.Stream;

@RestController
public class Profile {
    private static final Logger logger = LoggerFactory.getLogger("ActivityPub");
    @Inject
    private UserService userService;
    @Inject
    private MessagesService messagesService;
    @Inject
    private ActivityPubManager activityPubManager;
    @Inject
    private ProfileUriBuilder profileUriBuilder;
    @Inject
    private ApplicationEventPublisher applicationEventPublisher;
    @Inject
    private CommandsManager commandsManager;
    @Value("${web_domain:localhost}")
    private String domain;
    @Value("${ap_base_uri:http://localhost:8080/}")
    private String baseUri;
    @Inject
    private ObjectMapper jsonMapper;
    @Inject
    private Remark remarkConverter;
    @Inject
    private User serviceUser;
    @Inject
    private ConversionService conversionService;

    @GetMapping(value = "/u/{userName}", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public Actor getUser(@PathVariable String userName) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous()) {
            if (user.equals(serviceUser)) {
                user.setService(true);
            }
            return conversionService.convert(user, Actor.class);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/u/{userName}/blog/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public OrderedCollection getOutbox(@PathVariable String userName) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous()) {
            UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri);
            OrderedCollection blog = new OrderedCollection();
            blog.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
            blog.setTotalItems(userService.getStatsMessages(user.getUid()));
            blog.setFirst(uriComponentsBuilder.path(String.format("/u/%s/blog", userName)).toUriString());
            return (OrderedCollection) Context.build(blog);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/u/{userName}/blog", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public OrderedCollectionPage getOutboxPage(@Parameter(hidden = true) User visitor, @PathVariable String userName,
                                               @RequestParam(required = false, defaultValue = "0") int before) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous() && !user.isBanned()) {
            UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri);
            String personUri = uri.path(String.format("/u/%s", userName)).toUriString();
            List<Integer> mids = messagesService.getUserBlog(user.getUid(), 0, before);
            List<Note> notes = messagesService.getMessages(visitor.getUid(), mids).stream().map(activityPubManager::makeNote)
                    .toList();
            OrderedCollectionPage page = new OrderedCollectionPage();
            page.setPartOf(uri.replacePath(String.format("/u/%s/blog/toc", userName)).toUriString());
            page.setFirst(uri.replacePath(String.format("/u/%s/blog", userName)).toUriString());
            page.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
            page.setOrderedItems(notes.stream().map(a -> {
                Create create = new Create();
                create.setId(a.getId() + "#Create");
                create.setTo(a.getTo());
                create.setActor(personUri);
                create.setObject(a);
                create.setPublished(a.getPublished());
                return create;
            }).toList());
            int beforeNext = mids.stream().reduce((fst, second) -> second).orElse(0);
            if (beforeNext > 0) {
                page.setNext(uri.queryParam("before", beforeNext).toUriString());
            }
            page.setLast(uri.replaceQueryParam("before", "1").toUriString());
            return (OrderedCollectionPage) Context.build(page);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/u/{userName}/followers/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public OrderedCollection getFollowers(@PathVariable String userName) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous()) {
            UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri);
            OrderedCollection followers = new OrderedCollection();
            followers.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
            followers.setTotalItems(userService.getUserReaders(user.getUid()).size());
            followers.setFirst(uriComponentsBuilder.path(String.format("/u/%s/followers", userName)).toUriString());
            return (OrderedCollection) Context.build(followers);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/u/{userName}/followers", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public OrderedCollectionPage getFollowersPage(@PathVariable String userName,
            @RequestParam(required = false, defaultValue = "0") int page) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous()) {
            UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri);
            uriComponentsBuilder.path(String.format("/u/%s/followers", userName));
            List<User> followers = userService.getUserReaders(user.getUid());
            Stream<User> followersPage = followers.stream().skip(20 * page).limit(20);

            OrderedCollectionPage result = new OrderedCollectionPage();
            result.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
            result.setOrderedItems(followersPage.map(a -> {
                Person follower = new Person();
                follower.setName(a.getName());
                follower.setPreferredUsername(a.getName());
                follower.setUrl(profileUriBuilder.personWebUri(a));
                return follower;
            }).toList());
            boolean hasNext = followers.size() <= 20 * page;
            if (hasNext) {
                result.setNext(uriComponentsBuilder.queryParam("page", page + 1).toUriString());
            }
            return (OrderedCollectionPage) Context.build(result);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/u/{userName}/following/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public OrderedCollection getFollowing(@PathVariable String userName) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous()) {
            UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri);
            OrderedCollection following = new OrderedCollection();
            following.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
            following.setTotalItems(userService.getUserFriends(user.getUid()).size());
            following.setFirst(uriComponentsBuilder.path(String.format("/u/%s/followers", userName)).toUriString());
            return (OrderedCollection) Context.build(following);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/u/{userName}/following", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public OrderedCollectionPage getFollowingPage(@PathVariable String userName,
            @RequestParam(required = false, defaultValue = "0") int page) {
        User user = userService.getUserByName(userName);
        if (!user.isAnonymous()) {
            UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri);
            uriComponentsBuilder.path(String.format("/u/%s/following", userName));
            List<User> following = userService.getUserFriends(user.getUid());
            Stream<User> followingPage = following.stream().skip(20 * page).limit(20);

            OrderedCollectionPage result = new OrderedCollectionPage();
            result.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString());
            result.setOrderedItems(followingPage.map(a -> {
                Person follower = new Person();
                follower.setName(a.getName());
                follower.setPreferredUsername(a.getName());
                follower.setUrl(profileUriBuilder.personWebUri(a));
                return follower;
            }).toList());
            boolean hasNext = following.size() <= 20 * page;
            if (hasNext) {
                result.setNext(uriComponentsBuilder.queryParam("page", page + 1).toUriString());
            }
            return (OrderedCollectionPage) Context.build(result);
        }
        throw new HttpNotFoundException();
    }

    @GetMapping(value = "/n/{mid}-{rid}", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public Context showNote(@PathVariable int mid, @PathVariable int rid) {
        var message = activityPubManager.findMessage(mid, rid);
        if (message != null) {
            return Context.build(activityPubManager.makeNote(message));
        }
        throw new HttpNotFoundException();
    }

    private String formatNote(Note note) {
        String markdown = remarkConverter.convertFragment((String) note.getContent());
        // combine note text with attachment urls
        return note.getAttachment() == null ? markdown
                : note.getAttachment().stream().map(attachment -> {
                    String attachmentUrl = attachment.getUrl();
                    String attachmentName = attachment.getName();
                    return PlainTextFormatter.markdownUrl(attachmentUrl, attachmentName);
                }).reduce(markdown,
                        (currentUrl, nextUrl) -> String.format("%s\n%s", currentUrl, nextUrl));
    }

    @CacheEvict(cacheNames = "profiles", key = "{ #visitor.uri }")
    @PostMapping(value = "/api/inbox", consumes = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE,
        Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE })
    public ResponseEntity<CommandResult> processInbox(@Parameter(hidden = true) User visitor, InputStream body)
            throws Exception {
        var inboxBody = IOUtils.toString(body, StandardCharsets.UTF_8);
        logger.debug("Inbox: {}", inboxBody);
        var context = jsonMapper.readValue(inboxBody, Context.class);
        if (context instanceof Activity activity) {
            if ((StringUtils.isNotEmpty(visitor.getUri().toString())
                    && visitor.getUri().equals(URI.create(activity.getActor()))) || !visitor.isAnonymous()) {
                if (activity instanceof Follow) {
                    Follow followRequest = (Follow) activity;
                    applicationEventPublisher.publishEvent(new FollowEvent(this, followRequest));
                    return new ResponseEntity<>(CommandResult.fromString("Follow request accepted"), HttpStatus.ACCEPTED);
                }
                if (activity instanceof Undo) {
                    Context object = activity.getObject();
                    if (object instanceof Follow) {
                        applicationEventPublisher.publishEvent(
                                new UndoFollowEvent(this, activity.getActor(), ((Activity) object).getObject().getId()));
                        return new ResponseEntity<>(CommandResult.fromString("Undo follow request accepted"),
                                HttpStatus.OK);
                    } else if (object instanceof Like || object instanceof Announce) {
                        applicationEventPublisher.publishEvent(
                                new UndoAnnounceEvent(this, activity.getActor(), ((Activity) object).getObject().getId()));
                        return new ResponseEntity<>(CommandResult.fromString("Undo like/announce request accepted"),
                                HttpStatus.OK);
                    }
                }
                if (activity instanceof Create) {
                    if (activity.getObject() instanceof Note) {
                        Note note = (Note) activity.getObject();
                        URI noteId = URI.create(note.getId());
                        if (messagesService.replyExists(noteId)) {
                            return new ResponseEntity<>(CommandResult.fromString("Reply already exists"), HttpStatus.OK);
                        } else {
                            String inReplyTo = note.getInReplyTo();
                            if (StringUtils.isNotBlank(inReplyTo)) {
                                if (inReplyTo.startsWith(baseUri)) {
                                    String postId = profileUriBuilder.postId(inReplyTo);
                                    User user = new User();
                                    user.setUri(URI.create(activity.getActor()));
                                    CommandResult result = commandsManager.processCommand(user,
                                            String.format("#%s %s", postId, formatNote(note)),
                                            URI.create(StringUtils.EMPTY));
                                    logger.info(jsonMapper.writeValueAsString(result));
                                    if (result.getNewMessage().isPresent()) {
                                        messagesService.updateReplyUri(result.getNewMessage().get(), noteId);
                                        return new ResponseEntity<>(result, HttpStatus.OK);
                                    } else {
                                        logger.warn("Invalid request: {}", context.getId());
                                        return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
                                    }
                                } else {
                                    Message reply = messagesService.getReplyByUri(inReplyTo);
                                    if (reply != null) {
                                        User user = new User();
                                        user.setUri(URI.create(activity.getActor()));
                                        CommandResult result = commandsManager.processCommand(user,
                                                String.format("#%d/%d %s", reply.getMid(), reply.getRid(),
                                                        formatNote(note)),
                                                URI.create(StringUtils.EMPTY));
                                        logger.info(jsonMapper.writeValueAsString(result));
                                        if (result.getNewMessage().isPresent()) {
                                            messagesService.updateReplyUri(result.getNewMessage().get(), noteId);
                                            return new ResponseEntity<>(result, HttpStatus.OK);
                                        }
                                    }
                                }
                            } else {
                                if (note.getTo().stream().anyMatch(recipient -> recipient.startsWith(baseUri))) {
                                    logger.warn("Possible direct message from {}", note.getAttributedTo());
                                    applicationEventPublisher.publishEvent(new DirectMessageEvent(this, note));
                                    return new ResponseEntity<>(CommandResult.fromString("Message accepted"),
                                            HttpStatus.ACCEPTED);
                                }
                            }
                            logger.warn("Request with invalid recipient from {}", activity.getActor());
                            return new ResponseEntity<>(CommandResult.fromString("Message accepted"), HttpStatus.ACCEPTED);
                        }
                    }
                }
                if (activity instanceof Delete) {
                    URI objectId = URI.create(activity.getObject().getId());
                    if (messagesService.replyExists(objectId)) {
                        URI actor = URI.create(activity.getActor());
                        messagesService.deleteReply(actor, objectId);
                    }
                    // accept all authenticated Delete activities
                    return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"), HttpStatus.ACCEPTED);
                }
                if (activity instanceof Like || activity instanceof Announce) {
                    String messageUri = activity.getObject().getId();
                    applicationEventPublisher.publishEvent(new AnnounceEvent(this, activity.getActor(), messageUri));
                    return new ResponseEntity<>(CommandResult.fromString("Like/announce request accepted"), HttpStatus.OK);
                }
                if (activity instanceof Flag) {
                    URI actor = URI.create(activity.getActor());
                    logger.info("{} flag some objects: {}", actor, activity.getObject());
                    return new ResponseEntity<>(CommandResult.fromString("Report accepted"), HttpStatus.ACCEPTED);
                }
                if (activity instanceof Update) {
                    if (activity.getObject() instanceof Person
                            && activity.getActor().equals(activity.getObject().getId())) {
                        logger.info("{} update they profile");
                        return new ResponseEntity<>(CommandResult.fromString("Update accepted"), HttpStatus.ACCEPTED);
                    }
                    if (activity.getObject() instanceof Note) {
                        Note note = (Note) activity.getObject();
                        logger.info("Got update to {}", note.getId());
                        if (activity.getActor().equals(note.getAttributedTo())) {
                            Message reply = messagesService.getReplyByUri(note.getId());
                            if (reply != null) {
                                if (messagesService.updateMessage(reply.getMid(), reply.getRid(), formatNote(note), true)) {
                                    logger.info("{} update they message {}", activity.getActor(), note.getId());
                                    return new ResponseEntity<>(HttpStatus.ACCEPTED);
                                }
                                logger.warn("Unable to update {}", note.getId());
                                return new ResponseEntity<>(HttpStatus.SERVICE_UNAVAILABLE);
                            } else {
                                logger.warn("Update not found: {}", note.getId());
                                return new ResponseEntity<>(HttpStatus.ACCEPTED);
                            }
                        } else {
                            logger.warn("Invalid Update: {}", jsonMapper.writeValueAsString(activity));
                            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
                        }
                    }
                }
                if (activity instanceof Block) {
                    logger.info("{} blocks {} (room_full_of_people_who_care.jpg)", activity.getActor(), activity.getObject().getId());
                }
                logger.warn("Unknown activity: {}", jsonMapper.writeValueAsString(activity));
                return new ResponseEntity<>(CommandResult.fromString("Unknown activity"), HttpStatus.NOT_IMPLEMENTED);
            }
            if (activity instanceof Delete) {
                // Delete gone user
                if (activity.getActor().equals(activity.getObject().getId())) {
                    return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"), HttpStatus.ACCEPTED);
                }
            }
        } else {
            return new ResponseEntity<>(CommandResult.fromString("Can't parse"), HttpStatus.BAD_REQUEST);
        }
        return new ResponseEntity<>(CommandResult.fromString("Can not authenticate"), HttpStatus.UNAUTHORIZED);
    }

    @PostMapping(value = "/api/u/", produces = MediaType.APPLICATION_JSON_VALUE)
    public User fetchUser(@RequestParam URI uri) throws JsonProcessingException, HttpBadRequestException {
        return activityPubManager.actorToUser(uri);
    }
}