Moar federation
This commit is contained in:
parent
32a12f25dc
commit
301364b8a7
4 changed files with 299 additions and 7 deletions
20
CLAUDE.md
20
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
127
src/main/java/org/operaton/fitpub/model/dto/ActorDTO.java
Normal file
127
src/main/java/org/operaton/fitpub/model/dto/ActorDTO.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue