/* * 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 . */ package com.juick.www.api; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.juick.model.Chat; import com.juick.model.Message; import com.juick.model.User; import com.juick.service.ChatService; import com.juick.service.TagService; import com.juick.service.UserService; import com.juick.www.WebApp; import com.juick.www.api.webfinger.model.Account; 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.web.bind.annotation.*; import javax.inject.Inject; import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.UUID; 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; @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record ApplicationRequest(String clientName, String redirectUris, String scopes) { } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record ApplicationResponse(String id, String name, String redirectUri, String clientId, String clientSecret) { } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record CredentialAccount(String id, String username, String acct, String displayName, Integer followersCount, Integer followingCount, String avatar) { } private Collection parseScopes(String s) { return s != null ? Arrays.asList(s.split(" ")) : Collections.emptyList(); } @PostMapping(value = "/api/v1/apps", consumes = { MediaType.APPLICATION_JSON_VALUE }) public ApplicationResponse apps(@RequestBody ApplicationRequest application) { return apps(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) { 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(redirectUris) .scopes((coll) -> coll.addAll(parseScopes(scopes))) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build(); registeredClientRepository.save(registeredClient); return new ApplicationResponse( registeredClient.getId(), registeredClient.getClientName(), String.join(",", registeredClient.getRedirectUris()), registeredClient.getClientId(), secret); } public record Instance(String domain) { } @GetMapping({ "/api/v1/instance", "/api/v2/instance" }) public Instance getInstance() { return new Instance(domain); } @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" }) public ResponseEntity> 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 context, Instant expiresAt, Boolean irreversible, Boolean wholeWord) { } @GetMapping("/api/v1/filters") public List 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.valueOf(userId)).orElseThrow(); var readers = userService.getUserReaders(Integer.valueOf(visitorId)); var friends = userService.getUserFriends(Integer.valueOf(visitorId)); var bl = userService.getUserBLUsers(Integer.valueOf(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 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, String inReplyToId, String inReplyToAccountId, Boolean sensitive, String spoilerText, String visibility, String content) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record Conversation( String id, Boolean unread, List 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)); } public Status mapLastMessage(Chat chat) { return new Status( String.valueOf(chat.getLastMessageTimestamp().toEpochMilli()), chat.getLastMessageTimestamp(), null, null, false, "", "direct", chat.getLastMessageText() ); } public Conversation toConversation(User user, Chat chat) { return new Conversation( String.valueOf(chat.getUid()), chat.getUnread() != null && chat.getUnread().size() > 0, List.of(toAccount(user), toAccount(chat)), mapLastMessage(chat)); } @GetMapping("/api/v1/conversations") public List conversations(@ModelAttribute User visitor) { return chatService.getLastChats(visitor).stream().map( chat -> toConversation(visitor, chat) ).collect(Collectors.toList()); } }