/* * 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.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.CommandsManager; 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.util.formatters.PlainTextFormatter; import com.juick.www.WebApp; import com.juick.www.api.activity.helpers.ProfileUriBuilder; import io.swagger.v3.oas.annotations.Parameter; 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.net.URI; 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 CommandsManager commandsManager; @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 url, String displayName, Integer followersCount, Integer followingCount, String avatar, String avatarStatic, AccountSource source) { } private Collection 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(@Parameter(hidden = true) User visitor) { return toAccount(visitor); } @GetMapping({"/api/v1/announcements", "/api/v1/lists", "/api/v1/custom_emojis", "/api/v1/notifications"}) 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.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 relationships(@Parameter(hidden = true) 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 Tag(String name, String url) {} public Tag toTag(com.juick.model.Tag juickTag) { return new Tag(juickTag.getName(), profileUriBuilder.tagUri(juickTag)); } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record Status(String id, String url, Instant createdAt, CredentialAccount account, String inReplyToId, String inReplyToAccountId, Boolean sensitive, @JsonInclude String spoilerText, String visibility, String content, @JsonInclude List mediaAttachments, @JsonInclude List mentions, @JsonInclude List emojis, @JsonInclude List tags) { } @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(), profileUriBuilder.personWebUri(user), 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()), "https://juick.com/pm/inbox", chat.getLastMessageTimestamp(), toAccount(chat), null, null, false, "", "direct", chat.getLastMessageText(), List.of(), List.of(), List.of(), 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 conversations(@Parameter(hidden = true) User visitor) { return chatService.getLastChats(visitor).stream().map( this::toConversation ).collect(Collectors.toList()); } public Status toStatus(Message message) { List 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), PlainTextFormatter.formatUrl(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, List.of(), List.of(), message.getTags().stream().map(this::toTag).collect(Collectors.toList()) ); } @GetMapping("/api/v1/timelines/{timeline}") public List publicTimeline(@Parameter(hidden = true) User visitor, @PathVariable String timeline, @RequestParam(value = "max_id", required = false) String maxId, @RequestParam(value = "only_media", required = false, defaultValue = "false") Boolean media) { List 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.getUid(), 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 ancestors, @JsonInclude List descendants) { } @GetMapping("/api/v1/statuses/{mid}-{rid}/context") public Context thread(@Parameter(hidden = true) 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); } private Status post(User visitor, String status, String inReplyToId) throws Exception { if (StringUtils.isNotEmpty(inReplyToId)) { var message = activityPubManager.findMessage(inReplyToId); var command = MessageUtils.isReply(message) ? String.format("#%d/%d %s", message.getMid(), message.getRid(), status) : String.format("#%d %s", message.getMid(), status); var result = commandsManager.processCommand( visitor, command, URI.create(StringUtils.EMPTY)); if (result.getNewMessage().isPresent()) { return toStatus(result.getNewMessage().get()); } else { throw new HttpBadRequestException(); } } else { var result = commandsManager.processCommand(visitor, status, URI.create(StringUtils.EMPTY)); if (result.getNewMessage().isPresent()) { return toStatus(result.getNewMessage().get()); } else { throw new HttpBadRequestException(); } } } @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record NewStatus(String status, @JsonProperty("in_reply_to_id") String inReplyToId) {} @PostMapping(value = "/api/v1/statuses", consumes = MediaType.APPLICATION_JSON_VALUE) public Status postStatus(User visitor, @RequestBody NewStatus newStatus) throws Exception { return post(visitor, newStatus.status(), newStatus.inReplyToId()); } @PostMapping(value = "/api/v1/statuses", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) public Status postStatus( User visitor, @RequestParam(required = false) String status, @RequestParam(name = "in_reply_to_id", required = false) String inReplyToId) throws Exception { return post(visitor, status, inReplyToId); } }