From 301364b8a76a90af62882b430083b8578e8dd690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Wed, 3 Dec 2025 22:54:54 +0100 Subject: [PATCH] Moar federation --- CLAUDE.md | 20 ++- .../fitpub/controller/UserController.java | 131 ++++++++++++++++++ .../operaton/fitpub/model/dto/ActorDTO.java | 127 +++++++++++++++++ .../fitpub/repository/UserRepository.java | 28 ++++ 4 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/model/dto/ActorDTO.java diff --git a/CLAUDE.md b/CLAUDE.md index 0d02d3c..f345873 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -752,16 +752,22 @@ For ActivityPub federated posts and thumbnails: --- -### Phase 2: Social Features & Enhancements -- [ ] Likes and comments on activities -- [ ] Activity sharing (Announce/boost functionality) -- [ ] User search and discovery -- [ ] Followers/following lists UI +### Phase 2: Social Features & Enhancements ✅ +- [x] Likes on activities (Like entity, repository, ActivityPub Like activity support) +- [x] Comments on activities (Comment entity, repository, ActivityPub Note activity support) +- [x] Activity sharing (Announce/boost functionality via ActivityPub) +- [x] Privacy protection for GPS tracks (fuzzy start/finish zones) +- [x] OSM tile rendering for activity maps (OsmTileRenderer with caching) +- [x] Activity image generation with track overlay (ActivityImageService) +- [x] FIT epoch timestamp fix (631065600 second offset for proper date handling) +- [x] Web Mercator projection for accurate track-to-map alignment +- [x] User search and discovery (UserRepository.searchUsers, UserRepository.findAllEnabledUsers, GET /api/users/search, GET /api/users/browse) +- [x] Followers/following lists (ActorDTO, GET /api/users/{username}/followers, GET /api/users/{username}/following) - [ ] Follower/following counts (populate with real data) - [ ] Notifications system -- [ ] Enhanced privacy controls +- [ ] Enhanced privacy controls UI - [ ] Follow/unfollow buttons on user profiles -- [ ] Activity visib[69287079d5e0a4532ba818ee.fit](src/test/resources/69287079d5e0a4532ba818ee.fit)ility to followers (implement FOLLOWERS visibility) +- [ ] Activity visibility to followers (implement FOLLOWERS visibility enforcement) - [ ] Breadcrumb navigation - [ ] Active route highlighting in navigation - [ ] Heart rate chart over time on activity details diff --git a/src/main/java/org/operaton/fitpub/controller/UserController.java b/src/main/java/org/operaton/fitpub/controller/UserController.java index e3457a0..39bfd81 100644 --- a/src/main/java/org/operaton/fitpub/controller/UserController.java +++ b/src/main/java/org/operaton/fitpub/controller/UserController.java @@ -3,18 +3,26 @@ package org.operaton.fitpub.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.ActorDTO; import org.operaton.fitpub.model.dto.UserDTO; import org.operaton.fitpub.model.dto.UserUpdateRequest; +import org.operaton.fitpub.model.entity.Follow; +import org.operaton.fitpub.model.entity.RemoteActor; import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.RemoteActorRepository; import org.operaton.fitpub.repository.UserRepository; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; /** @@ -28,6 +36,7 @@ public class UserController { private final UserRepository userRepository; private final FollowRepository followRepository; + private final RemoteActorRepository remoteActorRepository; @Value("${fitpub.base-url}") private String baseUrl; @@ -140,4 +149,126 @@ public class UserController { return ResponseEntity.ok(dto); } + + /** + * Search for users by username or display name. + * + * @param query search query + * @param pageable pagination parameters (page, size, sort) + * @return page of matching users + */ + @GetMapping("/search") + public ResponseEntity> searchUsers( + @RequestParam("q") String query, + Pageable pageable + ) { + log.debug("Searching users with query: {}, page: {}, size: {}", + query, pageable.getPageNumber(), pageable.getPageSize()); + + Page users = userRepository.searchUsers(query, pageable); + Page userDTOs = users.map(user -> { + UserDTO dto = UserDTO.fromEntity(user); + populateSocialCounts(dto, user); + return dto; + }); + + return ResponseEntity.ok(userDTOs); + } + + /** + * Browse all enabled users. + * + * @param pageable pagination parameters (page, size, sort) + * @return page of users + */ + @GetMapping("/browse") + public ResponseEntity> browseUsers(Pageable pageable) { + log.debug("Browsing all users, page: {}, size: {}", + pageable.getPageNumber(), pageable.getPageSize()); + + Page users = userRepository.findAllEnabledUsers(pageable); + Page userDTOs = users.map(user -> { + UserDTO dto = UserDTO.fromEntity(user); + populateSocialCounts(dto, user); + return dto; + }); + + return ResponseEntity.ok(userDTOs); + } + + /** + * Get list of followers for a user. + * + * @param username the username + * @return list of followers + */ + @GetMapping("/{username}/followers") + public ResponseEntity> getFollowers(@PathVariable String username) { + log.debug("Retrieving followers for user: {}", username); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + String actorUri = user.getActorUri(baseUrl); + List followers = followRepository.findAcceptedFollowersByActorUri(actorUri); + + List actorDTOs = new ArrayList<>(); + for (Follow follow : followers) { + // For each follower, check if it's a local user or remote actor + if (follow.getFollowerId() != null) { + // Local follower + userRepository.findById(follow.getFollowerId()).ifPresent(follower -> { + actorDTOs.add(ActorDTO.fromLocalUser(follower, baseUrl, follow.getCreatedAt())); + }); + } else if (follow.getRemoteActorUri() != null) { + // Remote follower + remoteActorRepository.findByActorUri(follow.getRemoteActorUri()).ifPresent(remoteActor -> { + actorDTOs.add(ActorDTO.fromRemoteActor(remoteActor, follow.getCreatedAt())); + }); + } + } + + log.debug("Found {} followers for user {}", actorDTOs.size(), username); + return ResponseEntity.ok(actorDTOs); + } + + /** + * Get list of users that this user is following. + * + * @param username the username + * @return list of following + */ + @GetMapping("/{username}/following") + public ResponseEntity> getFollowing(@PathVariable String username) { + log.debug("Retrieving following for user: {}", username); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + List following = followRepository.findAcceptedFollowingByUserId(user.getId()); + + List actorDTOs = new ArrayList<>(); + for (Follow follow : following) { + String followingActorUri = follow.getFollowingActorUri(); + + // Check if it's a local user by trying to extract username from actor URI + // Format: https://fitpub.example/users/username + if (followingActorUri.startsWith(baseUrl)) { + String followingUsername = followingActorUri.substring( + followingActorUri.lastIndexOf("/") + 1 + ); + userRepository.findByUsername(followingUsername).ifPresent(followedUser -> { + actorDTOs.add(ActorDTO.fromLocalUser(followedUser, baseUrl, follow.getCreatedAt())); + }); + } else { + // Remote actor + remoteActorRepository.findByActorUri(followingActorUri).ifPresent(remoteActor -> { + actorDTOs.add(ActorDTO.fromRemoteActor(remoteActor, follow.getCreatedAt())); + }); + } + } + + log.debug("Found {} following for user {}", actorDTOs.size(), username); + return ResponseEntity.ok(actorDTOs); + } } diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActorDTO.java b/src/main/java/org/operaton/fitpub/model/dto/ActorDTO.java new file mode 100644 index 0000000..dbee820 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/ActorDTO.java @@ -0,0 +1,127 @@ +package org.operaton.fitpub.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.operaton.fitpub.model.entity.RemoteActor; +import org.operaton.fitpub.model.entity.User; + +import java.time.Instant; + +/** + * DTO representing an actor (local user or remote actor) in follower/following lists. + * Provides a unified representation regardless of whether the actor is local or remote. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActorDTO { + + /** + * The ActivityPub actor URI. + * Example: https://fitpub.example/users/alice or https://mastodon.social/users/bob + */ + private String actorUri; + + /** + * The username. + * Example: alice + */ + private String username; + + /** + * The domain of the actor's server. + * Example: fitpub.example or mastodon.social + */ + private String domain; + + /** + * The full handle in format username@domain. + * Example: alice@fitpub.example or bob@mastodon.social + */ + private String handle; + + /** + * The display name. + */ + private String displayName; + + /** + * The actor's avatar URL. + */ + private String avatarUrl; + + /** + * The actor's bio/summary. + */ + private String bio; + + /** + * Whether this is a local actor (true) or remote actor (false). + */ + private boolean local; + + /** + * When the follow relationship was created. + */ + private Instant followedAt; + + /** + * Create ActorDTO from a local User entity. + * + * @param user the local user + * @param baseUrl the base URL of this server + * @param followedAt when the follow relationship was created + * @return ActorDTO representing the local user + */ + public static ActorDTO fromLocalUser(User user, String baseUrl, Instant followedAt) { + String domain = extractDomainFromUrl(baseUrl); + return ActorDTO.builder() + .actorUri(user.getActorUri(baseUrl)) + .username(user.getUsername()) + .domain(domain) + .handle(user.getUsername() + "@" + domain) + .displayName(user.getDisplayName()) + .avatarUrl(user.getAvatarUrl()) + .bio(user.getBio()) + .local(true) + .followedAt(followedAt) + .build(); + } + + /** + * Create ActorDTO from a RemoteActor entity. + * + * @param remoteActor the remote actor + * @param followedAt when the follow relationship was created + * @return ActorDTO representing the remote actor + */ + public static ActorDTO fromRemoteActor(RemoteActor remoteActor, Instant followedAt) { + return ActorDTO.builder() + .actorUri(remoteActor.getActorUri()) + .username(remoteActor.getUsername()) + .domain(remoteActor.getDomain()) + .handle(remoteActor.getHandle()) + .displayName(remoteActor.getDisplayName()) + .avatarUrl(remoteActor.getAvatarUrl()) + .bio(remoteActor.getSummary()) + .local(false) + .followedAt(followedAt) + .build(); + } + + /** + * Extract domain from a base URL. + * Example: http://localhost:8080 -> localhost:8080 + * Example: https://fitpub.example -> fitpub.example + */ + private static String extractDomainFromUrl(String url) { + try { + return url.replaceFirst("^https?://", ""); + } catch (Exception e) { + return url; + } + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/UserRepository.java b/src/main/java/org/operaton/fitpub/repository/UserRepository.java index 6839d60..ac0a715 100644 --- a/src/main/java/org/operaton/fitpub/repository/UserRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/UserRepository.java @@ -1,9 +1,14 @@ package org.operaton.fitpub.repository; import org.operaton.fitpub.model.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -57,4 +62,27 @@ public interface UserRepository extends JpaRepository { * @return list of enabled users */ Optional findByUsernameAndEnabledTrue(String username); + + /** + * Searches for users by username or display name (case-insensitive). + * Used for user discovery. + * + * @param query the search query + * @param pageable pagination parameters + * @return page of matching users + */ + @Query("SELECT u FROM User u WHERE u.enabled = true AND " + + "(LOWER(u.username) LIKE LOWER(CONCAT('%', :query, '%')) OR " + + "LOWER(u.displayName) LIKE LOWER(CONCAT('%', :query, '%')))") + Page searchUsers(@Param("query") String query, Pageable pageable); + + /** + * Finds all enabled users with pagination. + * Used for browsing all users. + * + * @param pageable pagination parameters + * @return page of enabled users + */ + @Query("SELECT u FROM User u WHERE u.enabled = true ORDER BY u.createdAt DESC") + Page findAllEnabledUsers(Pageable pageable); }