aboutsummaryrefslogblamecommitdiff
path: root/src/main/java/com/juick/www/api/Mastodon.java
blob: 5982209e165f81120f1ed7e2d12f17f8980f54f8 (plain) (tree)

















                                                                           
                                                    
                                                     
                                                               
                                    
                               
                            
                                     
                                         
                                    
                                     
                                              
                            
                                                            
                                
                                            
                                                          
                                           
                                          
                                               



                                                                                                  
                                                                                       

                                                 
                          
                         
                   
                                   









                                                          


                            


                                    


                                          
 


                                                               
     
                                                                 
                                                


                                                            
     
                                                  
                                                                 
                                                                            

                                                           
                                                  
                                                        
                                                           
     
 


                                                                                 
                                                                                                   



                                                                                                 
                                                                         
                                                                         
                                                                                  
                                         
                                                                   
                                                                                                  





                                                                                                            
                        
     
 

























                                                                                                       
     
                                                         
                                   
                                                                                                 
     
 
                                                                    

                                  
                                                                                                             



                                                              

     






                                                                        
                                                                 








                                                                                    

                                                                                                                      


                                                                             


                                                                                                                 








                                                                     
                                                                                                                       
                                  
                                                                             
                                       
 
                                                                 
                                                                                                                                
                                                                                                  
     




                                                                 

       
                                                   
                                     




                                                                 

                                                                     



                                                                              
                                               
                                



                         
                                          
          
 
                                                   

                                                                        
                                         

                                      



                                                                                

                                                                           
                                    
                                       
                                             






                                                                                     
                          
                                                     
                                     
                                             
                                                                                                                      

                                                  
                                                                                    




                                                                     
                                                                                                      
                                                                                                                                     





                                                                    








                                                                             


                                                                            
                                              













                                                                                                       
 
/*
 * Copyright (C) 2008-2023, 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;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.juick.ActivityPubManager;
import com.juick.model.Chat;
import com.juick.model.Message;
import com.juick.model.User;
import com.juick.service.ChatService;
import com.juick.service.MessagesService;
import com.juick.service.TagService;
import com.juick.service.UserService;
import com.juick.util.HttpBadRequestException;
import com.juick.util.MessageUtils;
import com.juick.www.WebApp;
import com.juick.www.api.activity.helpers.ProfileUriBuilder;
import jakarta.validation.Valid;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.web.bind.annotation.*;

import javax.inject.Inject;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@RestController
public class Mastodon {
    @Value("${web_domain:localhost}")
    private String domain;
    @Inject
    WebApp webApp;
    @Inject
    UserService userService;
    @Inject
    RegisteredClientRepository registeredClientRepository;
    @Inject
    TagService tagService;
    @Inject
    ChatService chatService;
    @Inject
    MessagesService messagesService;
    @Inject
    User serviceUser;
    @Inject
    ActivityPubManager activityPubManager;
    @Inject
    ProfileUriBuilder profileUriBuilder;

    public record ApplicationRequest(
            @JsonProperty("client_name") String clientName,
            @JsonProperty("redirect_uris") String redirectUris,
            @JsonProperty("scopes") String scopes) {
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record ApplicationResponse(String id,
                                      String name,
                                      String redirectUri,
                                      String clientId,
                                      String clientSecret) {
    }

    public record AccountSource(String privacy) {}

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record CredentialAccount(String id, String username, String acct,
                                    String displayName,
                                    Integer followersCount,
                                    Integer followingCount,
                                    String avatar,
                                    String avatarStatic,
                                    AccountSource source) {
    }

    private Collection<String> parseScopes(String s) {
        return s != null ? Arrays.asList(s.split(" ")) : Collections.emptyList();
    }

    private ApplicationResponse registerApp(String clientName, String redirectUri, String scopes) {
        var secret = UUID.randomUUID().toString();
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId(UUID.randomUUID().toString())
                .clientSecret("{noop}" + secret)
                .clientName(clientName)
                .clientAuthenticationMethods(coll -> coll.addAll(List.of(
                        ClientAuthenticationMethod.CLIENT_SECRET_POST,
                        ClientAuthenticationMethod.CLIENT_SECRET_BASIC)))
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri(redirectUri)
                .scopes((coll) -> coll.addAll(parseScopes(scopes)))
                .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(365))
                        .refreshTokenTimeToLive(Duration.ofDays(365 * 5)).build())
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();
        registeredClientRepository.save(registeredClient);
        return new ApplicationResponse(
                registeredClient.getId(),
                registeredClient.getClientName(),
                String.join(",", registeredClient.getRedirectUris()),
                registeredClient.getClientId(),
                secret);
    }

    @PostMapping(value = "/api/v1/apps", consumes = {MediaType.APPLICATION_JSON_VALUE})
    public ApplicationResponse apps(@RequestBody @Valid ApplicationRequest application) {
        return registerApp(application.clientName(), application.redirectUris(), application.scopes());
    }

    @PostMapping(value = "/api/v1/apps", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE,
            MediaType.MULTIPART_FORM_DATA_VALUE})
    public ApplicationResponse apps(@RequestParam("client_name") String clientName,
                                    @RequestParam("redirect_uris") String redirectUris,
                                    @RequestParam("scopes") String scopes) {
        return registerApp(clientName, redirectUris, scopes);
    }

    public record Contact(
            String email,
            CredentialAccount account
    ) {

    }

    public record Instance(
            String uri,
            String title,
            String description,
            String version,
            String email
    ) {
    }

    @GetMapping({"/api/v1/instance", "/api/v2/instance"})
    public Instance getInstance() {
        return new Instance(domain, "Microblogging service", "Juick", "2.x","support@juick.com");
    }

    @GetMapping("/api/v1/accounts/verify_credentials")
    public CredentialAccount account(@ModelAttribute User visitor) {
        return toAccount(visitor);
    }

    @GetMapping({"/api/v1/announcements", "/api/v1/lists", "/api/v1/custom_emojis", "/api/v1/notifications"})
    public ResponseEntity<List<Void>> arrayStubs() {
        return new ResponseEntity<>(List.of(), HttpStatus.OK);
    }

    public record Preferences() {
    }

    ;

    @GetMapping("/api/v1/preferences")
    public Preferences preferences() {
        return new Preferences();
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record Filter(String id, String phrase, List<String> context,
                         Instant expiresAt, Boolean irreversible,
                         Boolean wholeWord) {

    }

    @GetMapping("/api/v1/filters")
    public List<Filter> filters() {
        return List.of();
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record Relationship(String id, Boolean following, Boolean showingReblogs,
                               Boolean notyfing, Boolean followingBy, Boolean blocked, Boolean blockedBy,
                               Boolean muting, Boolean mutingNotifications, Boolean requested, Boolean domainBlocking,
                               Boolean endorsed, String note) {

    }

    private Relationship findRelationships(String visitorId, String userId) {
        User user = userService.getUserByUID(Integer.parseInt(userId)).orElseThrow(HttpBadRequestException::new);
        var readers = userService.getUserReaders(Integer.parseInt(visitorId));
        var friends = userService.getUserFriends(Integer.parseInt(visitorId));
        var bl = userService.getUserBLUsers(Integer.parseInt(visitorId));
        var isFriend = friends.contains(user);
        var isReader = readers.contains(user);
        var isMuting = bl.contains(user);
        return new Relationship(userId,
                isFriend, isFriend, isFriend, isReader, false, false,
                isMuting, isMuting, false,
                false, false, "");
    }

    @GetMapping("/api/v1/accounts/relationships")
    public List<Relationship> relationships(@ModelAttribute User visitor, @RequestParam(value = "id[]") String[] ids) {
        return Stream.of(ids).map(
                id -> findRelationships(String.valueOf(visitor.getUid()), id)
        ).collect(Collectors.toList());
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record Status(String id, Instant createdAt, CredentialAccount account, String inReplyToId, String inReplyToAccountId,
                         Boolean sensitive, String spoilerText, String visibility, String content,
                         List<Image> mediaAttachments) {
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record Conversation(
            String id,
            Boolean unread,
            List<CredentialAccount> accounts,
            Status lastStatus
    ) {
    }

    public CredentialAccount toAccount(User user) {
        return new CredentialAccount(
                String.valueOf(user.getUid()),
                user.getName(),
                user.getName(),
                user.getFullName(),
                userService.getUserReaders(user.getUid()).size(),
                userService.getUserFriends(user.getUid()).size(),
                webApp.getAvatarUrl(user), webApp.getAvatarUrl(user),
                new AccountSource("public")
        );
    }

    public Status mapLastMessage(Chat chat) {
        return new Status(
                String.valueOf(chat.getLastMessageTimestamp().toEpochMilli()),
                chat.getLastMessageTimestamp(),
                toAccount(chat),
                null,
                null,
                false,
                "",
                "direct",
                chat.getLastMessageText(),
                List.of()
        );
    }

    public Conversation toConversation(Chat chat) {
        return new Conversation(
                String.valueOf(chat.getUid()),
                chat.getUnread() != null && chat.getUnread().size() > 0,
                List.of(toAccount(chat)),
                mapLastMessage(chat));
    }

    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public record Image(String id, String type, String url, String previewUrl) {

    }

    @GetMapping("/api/v1/conversations")
    public List<Conversation> conversations(@ModelAttribute User visitor) {
        return chatService.getLastChats(visitor).stream().map(
                this::toConversation
        ).collect(Collectors.toList());
    }

    public Status toStatus(Message message) {
        List<Image> attachments = StringUtils.isNotBlank(message.getAttachmentType())
                ? List.of(new Image(
                message.getAttachment().getUrl(),
                "image",
                message.getAttachment().getUrl(),
                message.getAttachment().getMedium().getUrl()
        ))
                : List.of();
        return new Status(
                ProfileUriBuilder.messageId(message),
                message.getCreated(),
                toAccount(message.getUser()),
                message.getReplyto() > 0 ? ProfileUriBuilder.messageId(message.getMid(), message.getReplyto()) : null,
                message.getTo().getUid() > 0 ? String.valueOf(message.getTo().getUid()) : null,
                MessageUtils.isSensitive(message),
                "",
                "public",
                activityPubManager.htmlLayout(message, profileUriBuilder.baseUri()),
                attachments
        );
    }

    @GetMapping("/api/v1/timelines/{timeline}")
    public List<Status> publicTimeline(@ModelAttribute User visitor,
                                       @PathVariable String timeline,
                                       @RequestParam(value = "max_id", required = false) String maxId,
                                       @RequestParam(value = "only_media", required = false, defaultValue = "false") Boolean media) {
        List<Integer> mids = List.of();
        int before = 0;
        if (maxId != null) {
            var lastMessage = activityPubManager.findMessage(maxId);
            if (lastMessage != null) {
                before = lastMessage.getMid();
            }
        }
        if (timeline.equals("public")) {
            if (media) {
                mids = messagesService.getPhotos(visitor.getUid(), before);
            } else {
                mids = messagesService.getAll(visitor.getUid(), before);
            }
        } else if (timeline.equals("home")) {
            mids = messagesService.getMyFeed(visitor.getUid(), before, true);
        }
        return messagesService.getMessages(visitor, mids).stream()
                .map(m -> {
                    m.getUser().setAvatar(webApp.getAvatarUrl(m.getUser()));
                    return toStatus(m);
                })
                .collect(Collectors.toList());
    }
    @GetMapping("/api/v1/statuses/{mid}-{rid}")
    public Status status(@PathVariable int mid, @PathVariable int rid) {
        return toStatus(activityPubManager.findMessage(mid, rid));
    }
    public record Context(@JsonInclude List<Status> ancestors, @JsonInclude List<Status> descendants) {

    }
    @GetMapping("/api/v1/statuses/{mid}-{rid}/context")
    public Context thread(@ModelAttribute User visitor, @PathVariable int mid, @PathVariable int rid) {
        var thread = messagesService.getReplies(visitor, mid).stream()
                .filter(m -> m.getRid() > rid)
                .peek(msg -> msg.getUser().setAvatar(webApp.getAvatarUrl(msg.getUser())))
                .map(this::toStatus).toList();
        return new Context(List.of(), thread);
    }
}