Moar federation

This commit is contained in:
Tim Zöller 2025-12-03 22:54:54 +01:00
parent 32a12f25dc
commit 301364b8a7
4 changed files with 299 additions and 7 deletions

View file

@ -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

View file

@ -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<Page<UserDTO>> searchUsers(
@RequestParam("q") String query,
Pageable pageable
) {
log.debug("Searching users with query: {}, page: {}, size: {}",
query, pageable.getPageNumber(), pageable.getPageSize());
Page<User> users = userRepository.searchUsers(query, pageable);
Page<UserDTO> 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<Page<UserDTO>> browseUsers(Pageable pageable) {
log.debug("Browsing all users, page: {}, size: {}",
pageable.getPageNumber(), pageable.getPageSize());
Page<User> users = userRepository.findAllEnabledUsers(pageable);
Page<UserDTO> 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<List<ActorDTO>> 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<Follow> followers = followRepository.findAcceptedFollowersByActorUri(actorUri);
List<ActorDTO> 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<List<ActorDTO>> getFollowing(@PathVariable String username) {
log.debug("Retrieving following for user: {}", username);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
List<Follow> following = followRepository.findAcceptedFollowingByUserId(user.getId());
List<ActorDTO> 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);
}
}

View file

@ -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;
}
}
}

View file

@ -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<User, UUID> {
* @return list of enabled users
*/
Optional<User> 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<User> 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<User> findAllEnabledUsers(Pageable pageable);
}