From a5686d4be2a0e82deeaedcd4194a732759832578 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Fri, 6 Jan 2023 09:42:13 +0300 Subject: Mastodon API: timelines and minor fixes --- src/main/java/com/juick/config/SecurityConfig.java | 17 ++- .../java/com/juick/service/ActivityPubService.java | 3 +- src/main/java/com/juick/www/api/Mastodon.java | 161 ++++++++++++++------- 3 files changed, 123 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/juick/config/SecurityConfig.java b/src/main/java/com/juick/config/SecurityConfig.java index 7fada80b..47033c11 100644 --- a/src/main/java/com/juick/config/SecurityConfig.java +++ b/src/main/java/com/juick/config/SecurityConfig.java @@ -52,7 +52,10 @@ import org.springframework.security.oauth2.server.authorization.config.annotatio import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.*; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.RememberMeServices; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices; import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; @@ -81,6 +84,8 @@ public class SecurityConfig { @Inject private JdbcTemplate jdbcTemplate; private static final String COOKIE_NAME = "juick-remember-me"; + @Value("${ap_base_uri:http://localhost:8080/}") + private String baseUri; @Bean UserDetailsService userDetailsService() { return new JuickUserDetailsService(userService); @@ -148,6 +153,8 @@ public class SecurityConfig { public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + var loginUrlAuthenticationEntryPoint = new LoginUrlAuthenticationEntryPoint("/login"); + loginUrlAuthenticationEntryPoint.setForceHttps(true); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .authorizationServerSettings(AuthorizationServerSettings.builder() .authorizationEndpoint("/oauth/authorize") @@ -158,7 +165,7 @@ public class SecurityConfig { // Redirect to the login page when not authenticated from the // authorization endpoint .exceptionHandling((exceptions) -> exceptions - .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")) + .authenticationEntryPoint(loginUrlAuthenticationEntryPoint) ) // Accept access tokens for User Info and/or Client Registration .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); @@ -169,7 +176,6 @@ public class SecurityConfig { public RegisteredClientRepository registeredClientRepository() { return new JdbcRegisteredClientRepository(jdbcTemplate); } - @Bean public JWKSource jwkSource() { RSAPublicKey publicKey = (RSAPublicKey) keystoreManager.getPublicKey(); @@ -185,11 +191,6 @@ public class SecurityConfig { public JwtDecoder jwtDecoder(JWKSource jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } - @Bean - public AuthorizationServerSettings authorizationServerSettings() { - return AuthorizationServerSettings.builder().build(); - } - @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 1) SecurityFilterChain apiChain(HttpSecurity http) throws Exception { diff --git a/src/main/java/com/juick/service/ActivityPubService.java b/src/main/java/com/juick/service/ActivityPubService.java index f161be7b..295910bd 100644 --- a/src/main/java/com/juick/service/ActivityPubService.java +++ b/src/main/java/com/juick/service/ActivityPubService.java @@ -38,6 +38,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.http.*; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -152,7 +153,7 @@ public class ActivityPubService extends BaseJdbcService implements SocialService } return Optional.of(context); } - } catch (IOException e) { + } catch (IOException | RestClientException e) { logger.warn("HTTP Signature exception: {}", e.getMessage()); } return Optional.empty(); diff --git a/src/main/java/com/juick/www/api/Mastodon.java b/src/main/java/com/juick/www/api/Mastodon.java index 9ea937d1..7591ec92 100644 --- a/src/main/java/com/juick/www/api/Mastodon.java +++ b/src/main/java/com/juick/www/api/Mastodon.java @@ -17,16 +17,20 @@ package com.juick.www.api; +import com.fasterxml.jackson.annotation.JsonProperty; 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.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.webfinger.model.Account; +import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -36,16 +40,13 @@ 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.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -63,27 +64,31 @@ public class Mastodon { TagService tagService; @Inject ChatService chatService; + @Inject + MessagesService messagesService; + @Inject + User serviceUser; - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record ApplicationRequest(String clientName, - String redirectUris, - String scopes) { + 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) { + 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) { + String displayName, + Integer followersCount, + Integer followingCount, + String avatar) { } @@ -91,16 +96,7 @@ public class Mastodon { 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) { + 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()) @@ -110,8 +106,9 @@ public class Mastodon { ClientAuthenticationMethod.CLIENT_SECRET_POST, ClientAuthenticationMethod.CLIENT_SECRET_BASIC))) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .redirectUri(redirectUris) + .redirectUri(redirectUri) .scopes((coll) -> coll.addAll(parseScopes(scopes))) + .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(365)).build()) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build(); registeredClientRepository.save(registeredClient); return new ApplicationResponse( @@ -122,12 +119,38 @@ public class Mastodon { secret); } - public record Instance(String domain) { + @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" }) + @GetMapping({"/api/v1/instance", "/api/v2/instance"}) public Instance getInstance() { - return new Instance(domain); + return new Instance(domain, "Microblogging service", "Juick", "2.x","support@juick.com"); } @GetMapping("/api/v1/accounts/verify_credentials") @@ -135,13 +158,15 @@ public class Mastodon { return toAccount(visitor); } - @GetMapping({ "/api/v1/announcements", "/api/v1/lists", "/api/v1/custom_emojis" }) + @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() { @@ -150,8 +175,8 @@ public class Mastodon { @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record Filter(String id, String phrase, List context, - Instant expiresAt, Boolean irreversible, - Boolean wholeWord) { + Instant expiresAt, Boolean irreversible, + Boolean wholeWord) { } @@ -162,17 +187,17 @@ public class Mastodon { @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) { + 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)); + 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); @@ -183,21 +208,26 @@ public class Mastodon { } @GetMapping("/api/v1/accounts/relationships") - public List relationships(@ModelAttribute User visitor, @RequestParam(value="id[]") String[] ids) { + public List relationships(@ModelAttribute User visitor, @RequestParam(value = "id[]") String[] ids) { return Stream.of(ids).map( - id -> findRelationships(String.valueOf(visitor.getUid()), id) + 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) {} + 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()), @@ -212,7 +242,7 @@ public class Mastodon { public Status mapLastMessage(Chat chat) { return new Status( String.valueOf(chat.getLastMessageTimestamp().toEpochMilli()), - chat.getLastMessageTimestamp(), + chat.getLastMessageTimestamp(), null, null, false, @@ -236,4 +266,37 @@ public class Mastodon { this::toConversation ).collect(Collectors.toList()); } + + public Status toStatus(Message message) { + return new Status( + String.valueOf(message.getMid()), + message.getCreated(), + null, + null, + MessageUtils.isSensitive(message), + "", + "public", + MessageUtils.formatMessage(message.getText()) + ); + } + + @GetMapping("/api/v1/timelines/{timeline}") + public List publicTimeline(@ModelAttribute User visitor, + @PathVariable String timeline, + @RequestParam(value = "max_id", required = false, defaultValue = "0") int before, + @RequestParam(value = "only_media", required = false, defaultValue = "false") Boolean media) { + List mids = List.of(); + 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(this::toStatus) + .collect(Collectors.toList()); + } } -- cgit v1.2.3