Compare commits

..

1 commit

Author SHA1 Message Date
6fbd551b2e
feat(profile): add configurable profile visibility with access control
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-05-02 00:11:20 +02:00
36 changed files with 474 additions and 1080 deletions

12
.gitignore vendored
View file

@ -5,7 +5,10 @@ target/
.kotlin .kotlin
### IntelliJ IDEA ### ### IntelliJ IDEA ###
.idea/ .idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws *.iws
*.iml *.iml
*.ipr *.ipr
@ -46,10 +49,3 @@ logs/
/gadm_410.gpkg /gadm_410.gpkg
/.postgresdata/ /.postgresdata/
/peaks_worldwide.geojson /peaks_worldwide.geojson
### Coding Assistants ###
.codex/
.aider*
.cursor/
.roo/
.windsurf/

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

17
.idea/dataSources.xml generated Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="testdb@localhost" uuid="2564811a-81f9-4d83-b1b1-04cb2763e3fa">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:51826/testdb</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/data_source_mapping.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
</component>
</project>

7
.idea/encodings.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

17
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ClojureProjectResolveSettings">
<currentScheme>IDE</currentScheme>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/sqldialects.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V26__add_published_to_activities.sql" dialect="H2" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View file

@ -23,7 +23,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>17</java.version> <java.version>17</java.version>
<jjwt.version>0.12.3</jjwt.version> <jjwt.version>0.12.3</jjwt.version>
<testcontainers.version>2.0.5</testcontainers.version> <testcontainers.version>2.0.3</testcontainers.version>
</properties> </properties>
<dependencies> <dependencies>
@ -170,14 +170,15 @@
<dependency> <dependency>
<groupId>org.testcontainers</groupId> <groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId> <artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version> <version>2.0.2</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.testcontainers</groupId> <groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId> <artifactId>testcontainers-postgresql</artifactId>
<version>${testcontainers.version}</version> <version>2.0.1</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>

View file

@ -166,7 +166,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/users/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated() .requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/users/me").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search .requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users .requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users

View file

@ -10,14 +10,13 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.RemoteActor; import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.User; import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator; import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.service.ActivityImageService; import net.javahippie.fitpub.service.ActivityImageService;
import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.InboxProcessor; import net.javahippie.fitpub.service.InboxProcessor;
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
import net.javahippie.fitpub.util.ActivityFormatter; import net.javahippie.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -30,7 +29,6 @@ import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.ZoneOffset;
import java.util.*; import java.util.*;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -53,7 +51,6 @@ public class ActivityPubController {
private final HttpSignatureValidator signatureValidator; private final HttpSignatureValidator signatureValidator;
private final FederationService federationService; private final FederationService federationService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; private String baseUrl;
@ -439,10 +436,9 @@ public class ActivityPubController {
noteObject.put("id", activityUri); noteObject.put("id", activityUri);
noteObject.put("type", "Note"); noteObject.put("type", "Note");
noteObject.put("attributedTo", actorUri); noteObject.put("attributedTo", actorUri);
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("published", activity.getCreatedAt().toString());
noteObject.put("content", formatActivityContent(activity)); noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", activityUri); noteObject.put("url", activityUri);
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
// Audience only PUBLIC activities reach this endpoint (the visibility // Audience only PUBLIC activities reach this endpoint (the visibility
// check above returned 403 for anything else), so audience is always // check above returned 403 for anything else), so audience is always

View file

@ -14,9 +14,11 @@ import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.ProfileAccessService;
import net.javahippie.fitpub.service.WebFingerClient; import net.javahippie.fitpub.service.WebFingerClient;
import net.javahippie.fitpub.service.UserService; import net.javahippie.fitpub.service.UserService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -25,6 +27,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -47,6 +50,7 @@ public class UserController {
private final WebFingerClient webFingerClient; private final WebFingerClient webFingerClient;
private final FederationService federationService; private final FederationService federationService;
private final UserService userService; private final UserService userService;
private final ProfileAccessService profileAccessService;
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
@ -68,6 +72,14 @@ public class UserController {
dto.setFollowingCount((long) followingCount); dto.setFollowingCount((long) followingCount);
} }
private User getCurrentUserOrNull(UserDetails userDetails) {
if (userDetails == null) {
return null;
}
return userRepository.findByUsername(userDetails.getUsername()).orElse(null);
}
/** /**
* Get current user's profile. * Get current user's profile.
* *
@ -111,6 +123,9 @@ public class UserController {
if (request.getBio() != null) { if (request.getBio() != null) {
user.setBio(request.getBio().trim()); user.setBio(request.getBio().trim());
} }
if (request.getProfileVisibility() != null) {
user.setProfileVisibility(request.getProfileVisibility());
}
if (request.getAvatarUrl() != null) { if (request.getAvatarUrl() != null) {
user.setAvatarUrl(request.getAvatarUrl().trim()); user.setAvatarUrl(request.getAvatarUrl().trim());
} }
@ -177,13 +192,21 @@ public class UserController {
* @return user profile * @return user profile
*/ */
@GetMapping("/{username}") @GetMapping("/{username}")
public ResponseEntity<UserDTO> getUserByUsername(@PathVariable String username) { public ResponseEntity<UserDTO> getUserByUsername(
@PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) {
log.debug("Retrieving profile for username: {}", username); log.debug("Retrieving profile for username: {}", username);
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
UserDTO dto = UserDTO.fromEntity(user); User viewer = getCurrentUserOrNull(userDetails);
profileAccessService.requireProfileAccess(user, viewer);
UserDTO dto = viewer != null && viewer.getId().equals(user.getId())
? UserDTO.fromEntity(user)
: UserDTO.fromEntityPublic(user);
populateSocialCounts(dto, user); populateSocialCounts(dto, user);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
@ -196,13 +219,21 @@ public class UserController {
* @return user profile * @return user profile
*/ */
@GetMapping("/id/{id}") @GetMapping("/id/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID id) { public ResponseEntity<UserDTO> getUserById(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails
) {
log.debug("Retrieving profile for user ID: {}", id); log.debug("Retrieving profile for user ID: {}", id);
User user = userRepository.findById(id) User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException("User not found")); .orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserDTO dto = UserDTO.fromEntity(user); User viewer = getCurrentUserOrNull(userDetails);
profileAccessService.requireProfileAccess(user, viewer);
UserDTO dto = viewer != null && viewer.getId().equals(user.getId())
? UserDTO.fromEntity(user)
: UserDTO.fromEntityPublic(user);
populateSocialCounts(dto, user); populateSocialCounts(dto, user);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
@ -623,13 +654,17 @@ public class UserController {
*/ */
@GetMapping("/{username}/peaks") @GetMapping("/{username}/peaks")
public ResponseEntity<java.util.List<Map<String, Object>>> getUserPeaks( public ResponseEntity<java.util.List<Map<String, Object>>> getUserPeaks(
@PathVariable String username @PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) { ) {
User user = userRepository.findByUsername(username).orElse(null); User user = userRepository.findByUsername(username).orElse(null);
if (user == null) { if (user == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
User viewer = getCurrentUserOrNull(userDetails);
profileAccessService.requireProfileAccess(user, viewer);
var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId()); var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId());
var result = projections.stream() var result = projections.stream()
.map(p -> { .map(p -> {
@ -645,4 +680,13 @@ public class UserController {
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(ResponseStatusException e) {
HttpStatus status = HttpStatus.valueOf(e.getStatusCode().value());
String message = e.getReason() != null ? e.getReason() : status.getReasonPhrase();
return ResponseEntity.status(status).body(new ErrorResponse(status.name(), message));
}
record ErrorResponse(String error, String message) {}
} }

View file

@ -24,6 +24,7 @@ public class UserDTO {
private String email; // Only shown to the user themselves private String email; // Only shown to the user themselves
private String displayName; private String displayName;
private String bio; private String bio;
private User.ProfileVisibility profileVisibility;
private String avatarUrl; private String avatarUrl;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@ -52,6 +53,7 @@ public class UserDTO {
.email(user.getEmail()) .email(user.getEmail())
.displayName(user.getDisplayName()) .displayName(user.getDisplayName())
.bio(user.getBio()) .bio(user.getBio())
.profileVisibility(user.getProfileVisibility())
.avatarUrl(user.getAvatarUrl()) .avatarUrl(user.getAvatarUrl())
.homeLatitude(user.getHomeLatitude()) .homeLatitude(user.getHomeLatitude())
.homeLongitude(user.getHomeLongitude()) .homeLongitude(user.getHomeLongitude())
@ -72,6 +74,7 @@ public class UserDTO {
.username(user.getUsername()) .username(user.getUsername())
.displayName(user.getDisplayName()) .displayName(user.getDisplayName())
.bio(user.getBio()) .bio(user.getBio())
.profileVisibility(user.getProfileVisibility())
.avatarUrl(user.getAvatarUrl()) .avatarUrl(user.getAvatarUrl())
.createdAt(user.getCreatedAt()) .createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt()) .updatedAt(user.getUpdatedAt())

View file

@ -7,6 +7,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import net.javahippie.fitpub.model.entity.User;
import org.hibernate.validator.constraints.URL; import org.hibernate.validator.constraints.URL;
/** /**
@ -24,6 +25,8 @@ public class UserUpdateRequest {
@Size(max = 500, message = "Bio must not exceed 500 characters") @Size(max = 500, message = "Bio must not exceed 500 characters")
private String bio; private String bio;
private User.ProfileVisibility profileVisibility;
@URL(message = "Avatar URL must be a valid URL") @URL(message = "Avatar URL must be a valid URL")
private String avatarUrl; private String avatarUrl;

View file

@ -9,7 +9,6 @@ import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type; import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import org.locationtech.jts.geom.LineString;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -138,12 +137,6 @@ public class RemoteActivity {
@Column(name = "track_geojson_url", length = 512) @Column(name = "track_geojson_url", length = 512)
private String trackGeojsonUrl; private String trackGeojsonUrl;
/**
* Simplified remote route geometry for local map rendering.
*/
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
private LineString simplifiedTrack;
/** /**
* Visibility level of the activity. * Visibility level of the activity.
*/ */

View file

@ -43,6 +43,11 @@ public class User {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String bio; private String bio;
@Enumerated(EnumType.STRING)
@Column(name = "profile_visibility", nullable = false, length = 20)
@Builder.Default
private ProfileVisibility profileVisibility = ProfileVisibility.FOLLOWERS;
@Column(name = "avatar_url") @Column(name = "avatar_url")
private String avatarUrl; private String avatarUrl;
@ -112,4 +117,10 @@ public class User {
public String getWebFingerAccount(String domain) { public String getWebFingerAccount(String domain) {
return String.format("acct:%s@%s", username, domain); return String.format("acct:%s@%s", username, domain);
} }
public enum ProfileVisibility {
PUBLIC,
FOLLOWERS,
PRIVATE
}
} }

View file

@ -12,7 +12,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.ZoneOffset;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -39,7 +38,6 @@ public class ActivityPostProcessingService {
private final ActivityImageService activityImageService; private final ActivityImageService activityImageService;
private final ActivityRepository activityRepository; private final ActivityRepository activityRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; private String baseUrl;
@ -201,10 +199,9 @@ public class ActivityPostProcessingService {
noteObject.put("id", activityUri); noteObject.put("id", activityUri);
noteObject.put("type", "Note"); noteObject.put("type", "Note");
noteObject.put("attributedTo", actorUri); noteObject.put("attributedTo", actorUri);
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("published", activity.getCreatedAt().toString());
noteObject.put("content", formatActivityContent(activity)); noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", baseUrl + "/activities/" + activity.getId()); noteObject.put("url", baseUrl + "/activities/" + activity.getId());
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
// Extract hashtags from user text and add as tags // Extract hashtags from user text and add as tags
List<String> hashtags = extractHashtags(activity); List<String> hashtags = extractHashtags(activity);

View file

@ -16,20 +16,11 @@ import net.javahippie.fitpub.repository.CommentRepository;
import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.LikeRepository; import net.javahippie.fitpub.repository.LikeRepository;
import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.repository.UserRepository;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.PrecisionModel;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@ -40,9 +31,6 @@ import java.util.UUID;
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class InboxProcessor { public class InboxProcessor {
private static final int GEOMETRY_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID);
private final UserRepository userRepository; private final UserRepository userRepository;
private final FollowRepository followRepository; private final FollowRepository followRepository;
@ -423,18 +411,15 @@ public class InboxProcessor {
// Parse published timestamp // Parse published timestamp
String publishedStr = (String) noteObject.get("published"); String publishedStr = (String) noteObject.get("published");
Instant publishedAt = parsePublishedAt(publishedStr); Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
// Build RemoteActivity entity // Build RemoteActivity entity
RemoteActivity remoteActivity = RemoteActivity.builder() RemoteActivity remoteActivity = RemoteActivity.builder()
.activityUri(activityUri) .activityUri(activityUri)
.remoteActorUri(actor) .remoteActorUri(actor)
.activityType(stringValue(workoutData.get("activityType"))) .activityType((String) workoutData.get("activityType"))
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity"))) .title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
.description(firstNonBlank( .description(stripHtml((String) noteObject.get("content")))
stringValue(workoutData.get("description")),
stripHtml((String) noteObject.get("content"))
))
.publishedAt(publishedAt) .publishedAt(publishedAt)
.totalDistance(parseLong(workoutData.get("distance"))) .totalDistance(parseLong(workoutData.get("distance")))
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration"))) .totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
@ -446,7 +431,6 @@ public class InboxProcessor {
.calories(parseInteger(workoutData.get("calories"))) .calories(parseInteger(workoutData.get("calories")))
.mapImageUrl(attachments.get("mapImage")) .mapImageUrl(attachments.get("mapImage"))
.trackGeojsonUrl(attachments.get("trackGeojson")) .trackGeojsonUrl(attachments.get("trackGeojson"))
.simplifiedTrack(extractRoute(workoutData))
.visibility(visibility) .visibility(visibility)
.activityPubObject(serializeToJson(noteObject)) .activityPubObject(serializeToJson(noteObject))
.build(); .build();
@ -721,88 +705,6 @@ public class InboxProcessor {
return workoutData; return workoutData;
} }
private String stringValue(Object value) {
return value != null ? String.valueOf(value) : null;
}
private LineString extractRoute(Map<String, Object> workoutData) {
Object routeObj = workoutData.get("route");
if (!(routeObj instanceof Map<?, ?> routeMap)) {
return null;
}
Object featuresObj = routeMap.get("features");
if (!(featuresObj instanceof java.util.List<?> features) || features.isEmpty()) {
return null;
}
for (Object featureObj : features) {
if (!(featureObj instanceof Map<?, ?> featureMap)) {
continue;
}
Object geometryObj = featureMap.get("geometry");
if (!(geometryObj instanceof Map<?, ?> geometryMap)) {
continue;
}
if (!"LineString".equals(geometryMap.get("type"))) {
continue;
}
LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates"));
if (lineString != null) {
return lineString;
}
}
return null;
}
private LineString parseLineStringCoordinates(Object coordinatesObj) {
if (!(coordinatesObj instanceof java.util.List<?> coordinateList) || coordinateList.size() < 2) {
return null;
}
java.util.List<Coordinate> coordinates = new java.util.ArrayList<>();
for (Object coordinateObj : coordinateList) {
Coordinate coordinate = parseCoordinate(coordinateObj);
if (coordinate == null) {
return null;
}
coordinates.add(coordinate);
}
if (coordinates.size() < 2) {
return null;
}
return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0]));
}
private Coordinate parseCoordinate(Object coordinateObj) {
if (!(coordinateObj instanceof java.util.List<?> coordinateValues) || coordinateValues.size() < 2) {
return null;
}
Double longitude = parseDouble(coordinateValues.get(0));
Double latitude = parseDouble(coordinateValues.get(1));
if (longitude == null || latitude == null) {
return null;
}
return new Coordinate(longitude, latitude);
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
/** /**
* Extract attachment URLs (map image, GeoJSON) from a Note object. * Extract attachment URLs (map image, GeoJSON) from a Note object.
*/ */
@ -922,44 +824,6 @@ public class InboxProcessor {
} }
} }
/**
* Parse ActivityPub published timestamps.
*
* <p>Preferred input is a full ISO-8601 instant with timezone/offset. Some
* remote implementations still send zoneless timestamps, so we accept those
* as a compatibility fallback and interpret them as UTC.
*/
private Instant parsePublishedAt(String publishedStr) {
if (publishedStr == null || publishedStr.isBlank()) {
return Instant.now();
}
try {
return Instant.parse(publishedStr);
} catch (DateTimeParseException ignored) {
// Fall through to compatibility parsers below.
}
try {
return OffsetDateTime.parse(publishedStr).toInstant();
} catch (DateTimeParseException ignored) {
// Fall through to compatibility parsers below.
}
try {
return ZonedDateTime.parse(publishedStr).toInstant();
} catch (DateTimeParseException ignored) {
// Fall through to compatibility parsers below.
}
try {
return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant();
} catch (DateTimeParseException e) {
log.warn("Failed to parse published timestamp: {}", publishedStr, e);
return Instant.now();
}
}
/** /**
* Serialize object to JSON string. * Serialize object to JSON string.
*/ */

View file

@ -0,0 +1,59 @@
package net.javahippie.fitpub.service;
import lombok.RequiredArgsConstructor;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.FORBIDDEN;
/**
* Central access policy for profile visibility checks.
*/
@Service
@RequiredArgsConstructor
public class ProfileAccessService {
private final FollowRepository followRepository;
@Value("${fitpub.base-url}")
private String baseUrl;
public boolean canViewProfile(User profileOwner, User viewer) {
if (viewer != null && viewer.getId().equals(profileOwner.getId())) {
return true;
}
User.ProfileVisibility visibility = profileOwner.getProfileVisibility() != null
? profileOwner.getProfileVisibility()
: User.ProfileVisibility.PUBLIC;
if (visibility == User.ProfileVisibility.PUBLIC) {
return true;
}
if (visibility == User.ProfileVisibility.PRIVATE || viewer == null) {
return false;
}
String actorUri = profileOwner.getActorUri(baseUrl);
return followRepository.findByFollowerIdAndFollowingActorUri(viewer.getId(), actorUri)
.filter(follow -> follow.getStatus() == Follow.FollowStatus.ACCEPTED)
.isPresent();
}
public String getAccessDeniedMessage(User profileOwner) {
return profileOwner.getProfileVisibility() == User.ProfileVisibility.FOLLOWERS
? "This profile is only visible to followers."
: "This profile is private.";
}
public void requireProfileAccess(User profileOwner, User viewer) {
if (!canViewProfile(profileOwner, viewer)) {
throw new ResponseStatusException(FORBIDDEN, getAccessDeniedMessage(profileOwner));
}
}
}

View file

@ -1,86 +0,0 @@
package net.javahippie.fitpub.service;
import lombok.RequiredArgsConstructor;
import net.javahippie.fitpub.model.dto.ActivityDTO;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.ActivityMetrics;
import net.javahippie.fitpub.model.entity.PrivacyZone;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Builds the proprietary workoutData payload for outbound ActivityPub Notes.
*/
@Service
@RequiredArgsConstructor
public class WorkoutDataPayloadBuilder {
private final PrivacyZoneService privacyZoneService;
private final TrackPrivacyFilter trackPrivacyFilter;
public Map<String, Object> build(Activity activity) {
Map<String, Object> workoutData = new HashMap<>();
workoutData.put("activityType", activity.getActivityType().name());
if (activity.getDescription() != null && !activity.getDescription().isBlank()) {
workoutData.put("description", activity.getDescription());
}
if (activity.getTotalDistance() != null) {
workoutData.put("distance", activity.getTotalDistance().longValue());
}
if (activity.getTotalDurationSeconds() != null) {
workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString());
}
if (activity.getElevationGain() != null) {
workoutData.put("elevationGain", activity.getElevationGain().intValue());
}
ActivityMetrics metrics = activity.getMetrics();
if (metrics != null) {
if (metrics.getAveragePaceSeconds() != null) {
workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString());
}
if (metrics.getAverageHeartRate() != null) {
workoutData.put("averageHeartRate", metrics.getAverageHeartRate());
}
if (metrics.getAverageSpeed() != null) {
workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue());
}
if (metrics.getMaxSpeed() != null) {
workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue());
}
if (metrics.getCalories() != null) {
workoutData.put("calories", metrics.getCalories());
}
}
Map<String, Object> route = buildRoutePayload(activity);
if (route != null) {
workoutData.put("route", route);
}
return workoutData;
}
private Map<String, Object> buildRoutePayload(Activity activity) {
List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter);
if (dto.getSimplifiedTrack() == null) {
return null;
}
Map<String, Object> feature = new HashMap<>();
feature.put("type", "Feature");
feature.put("geometry", dto.getSimplifiedTrack());
Map<String, Object> featureCollection = new HashMap<>();
featureCollection.put("type", "FeatureCollection");
featureCollection.put("features", List.of(feature));
return featureCollection;
}
}

View file

@ -98,10 +98,6 @@ public class ActivityFormatter {
* *
*/ */
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) { private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
if (timezone == null || timezone.isBlank()) {
return utcDateTime;
}
try { try {
return utcDateTime.atZone(ZoneOffset.UTC) return utcDateTime.atZone(ZoneOffset.UTC)
.withZoneSameInstant(ZoneId.of(timezone)) .withZoneSameInstant(ZoneId.of(timezone))

View file

@ -35,8 +35,7 @@ public final class ActivityPubContexts {
/** /**
* Returns the extended JSON-LD {@code @context} value for outbound objects * Returns the extended JSON-LD {@code @context} value for outbound objects
* that carry both interaction-policy declarations and FitPub's proprietary * that carry interaction-policy declarations. Shape:
* {@code workoutData} extension fields. Shape:
* *
* <pre> * <pre>
* [ * [
@ -46,20 +45,7 @@ public final class ActivityPubContexts {
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" }, * "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" }, * "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" }, * "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }, * "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
* "fitpub": "https://fitpub.social/ns#",
* "workoutData": "fitpub:workoutData",
* "activityType": "fitpub:activityType",
* "description": "fitpub:description",
* "distance": "fitpub:distance",
* "duration": "fitpub:duration",
* "elevationGain": "fitpub:elevationGain",
* "averagePace": "fitpub:averagePace",
* "averageHeartRate": "fitpub:averageHeartRate",
* "averageSpeed": "fitpub:averageSpeed",
* "maxSpeed": "fitpub:maxSpeed",
* "calories": "fitpub:calories",
* "route": "fitpub:route"
* } * }
* ] * ]
* </pre> * </pre>
@ -70,12 +56,6 @@ public final class ActivityPubContexts {
* Mastodon source, "interaction_policies" extension), so a Mastodon * Mastodon source, "interaction_policies" extension), so a Mastodon
* receiver compacting our object with its own context will recognise the * receiver compacting our object with its own context will recognise the
* field names and apply the policy. * field names and apply the policy.
*
* <p>The {@code fitpub:} prefix is FitPub's own extension namespace
* ({@code https://fitpub.social/ns#}). It declares the proprietary
* {@code workoutData} object and its structured activity fields so FitPub
* instances can exchange machine-readable workout metadata without
* overloading the standard ActivityStreams fields.
*/ */
public static List<Object> extendedContext() { public static List<Object> extendedContext() {
Map<String, Object> extensions = new LinkedHashMap<>(); Map<String, Object> extensions = new LinkedHashMap<>();
@ -84,19 +64,6 @@ public final class ActivityPubContexts {
extensions.put("canQuote", typedRef("gts:canQuote")); extensions.put("canQuote", typedRef("gts:canQuote"));
extensions.put("automaticApproval", typedRef("gts:automaticApproval")); extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
extensions.put("manualApproval", typedRef("gts:manualApproval")); extensions.put("manualApproval", typedRef("gts:manualApproval"));
extensions.put("fitpub", "https://fitpub.social/ns#");
extensions.put("workoutData", "fitpub:workoutData");
extensions.put("activityType", "fitpub:activityType");
extensions.put("description", "fitpub:description");
extensions.put("distance", "fitpub:distance");
extensions.put("duration", "fitpub:duration");
extensions.put("elevationGain", "fitpub:elevationGain");
extensions.put("averagePace", "fitpub:averagePace");
extensions.put("averageHeartRate", "fitpub:averageHeartRate");
extensions.put("averageSpeed", "fitpub:averageSpeed");
extensions.put("maxSpeed", "fitpub:maxSpeed");
extensions.put("calories", "fitpub:calories");
extensions.put("route", "fitpub:route");
return List.of( return List.of(
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
extensions extensions

View file

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN profile_visibility VARCHAR(20) NOT NULL DEFAULT 'FOLLOWERS';

View file

@ -1,9 +0,0 @@
ALTER TABLE remote_activities
ADD COLUMN simplified_track geometry(LineString, 4326);
CREATE INDEX idx_remote_activity_simplified_track
ON remote_activities
USING gist (simplified_track);
COMMENT ON COLUMN remote_activities.simplified_track IS
'Simplified remote route geometry for local map rendering';

View file

@ -323,6 +323,12 @@ const FitPubAuth = {
return; return;
} }
// Public profile pages are public (visibility is enforced by the profile API)
// Pattern: /users/{username}
if (currentPath.startsWith('/users/') && currentPath.split('/').length === 3) {
return;
}
if (currentPath.startsWith('/terms')) { if (currentPath.startsWith('/terms')) {
return; return;
} }

View file

@ -60,6 +60,17 @@
</div> </div>
</div> </div>
<!-- Profile visibility -->
<div class="mb-4">
<label for="profileVisibility" class="form-label">Profile Visibility</label>
<select class="form-select" id="profileVisibility" name="profileVisibility">
<option value="PUBLIC">Public - Anyone can see</option>
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
<option value="PRIVATE">Private - Only you can see</option>
</select>
<div class="form-text">Controls who can view your profile page.</div>
</div>
<!-- Home Location Section --> <!-- Home Location Section -->
<div class="mb-4"> <div class="mb-4">
<h5 class="mb-3"> <h5 class="mb-3">
@ -254,6 +265,7 @@
const formData = { const formData = {
displayName: document.getElementById('displayName').value.trim(), displayName: document.getElementById('displayName').value.trim(),
bio: document.getElementById('bio').value.trim(), bio: document.getElementById('bio').value.trim(),
profileVisibility: document.getElementById('profileVisibility').value,
avatarUrl: document.getElementById('avatarUrl').value.trim(), avatarUrl: document.getElementById('avatarUrl').value.trim(),
homeLatitude: document.getElementById('homeLatitude').value ? parseFloat(document.getElementById('homeLatitude').value) : null, homeLatitude: document.getElementById('homeLatitude').value ? parseFloat(document.getElementById('homeLatitude').value) : null,
homeLongitude: document.getElementById('homeLongitude').value ? parseFloat(document.getElementById('homeLongitude').value) : null, homeLongitude: document.getElementById('homeLongitude').value ? parseFloat(document.getElementById('homeLongitude').value) : null,
@ -320,6 +332,7 @@
function populateForm(user) { function populateForm(user) {
document.getElementById('displayName').value = user.displayName || ''; document.getElementById('displayName').value = user.displayName || '';
document.getElementById('bio').value = user.bio || ''; document.getElementById('bio').value = user.bio || '';
document.getElementById('profileVisibility').value = user.profileVisibility || 'FOLLOWERS';
document.getElementById('avatarUrl').value = user.avatarUrl || ''; document.getElementById('avatarUrl').value = user.avatarUrl || '';
document.getElementById('email').value = user.email || ''; document.getElementById('email').value = user.email || '';
document.getElementById('username').value = user.username || ''; document.getElementById('username').value = user.username || '';

View file

@ -24,6 +24,11 @@
<span id="errorMessage"></span> <span id="errorMessage"></span>
</div> </div>
<div id="accessNotice" class="alert alert-info d-none" role="alert">
<i class="bi bi-shield-lock"></i>
<span id="accessNoticeMessage"></span>
</div>
<!-- Profile Content --> <!-- Profile Content -->
<div id="profileContent" class="d-none"> <div id="profileContent" class="d-none">
<!-- Profile Header --> <!-- Profile Header -->
@ -46,7 +51,7 @@
<p class="text-muted mb-2"> <p class="text-muted mb-2">
<span id="username"></span> <span id="username"></span>
</p> </p>
<p id="bio" class="mb-3 preserve-linebreaks"></p> <p id="bio" class="mb-3"></p>
</div> </div>
<div id="followButtonContainer" class="d-none"> <div id="followButtonContainer" class="d-none">
<button class="btn btn-primary" id="followBtn"> <button class="btn btn-primary" id="followBtn">
@ -156,10 +161,20 @@
function loadProfile() { function loadProfile() {
// For now, we'll fetch from the user API endpoint // For now, we'll fetch from the user API endpoint
// In the future, this should use /api/users/{username} // In the future, this should use /api/users/{username}
fetch(`/api/users/${targetUsername}`) fetch(`/api/users/${targetUsername}`, {
headers: {
'Accept': 'application/json'
}
})
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
throw new Error('User not found'); return response.json()
.catch(() => ({}))
.then(errorData => {
const error = new Error(errorData.message || 'User not found');
error.status = response.status;
throw error;
});
} }
return response.json(); return response.json();
}) })
@ -171,8 +186,13 @@
.catch(error => { .catch(error => {
console.error('Error loading profile:', error); console.error('Error loading profile:', error);
document.getElementById('loadingIndicator').classList.add('d-none'); document.getElementById('loadingIndicator').classList.add('d-none');
if (error.status === 403) {
document.getElementById('accessNoticeMessage').textContent = error.message;
document.getElementById('accessNotice').classList.remove('d-none');
} else {
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.'; document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
document.getElementById('errorAlert').classList.remove('d-none'); document.getElementById('errorAlert').classList.remove('d-none');
}
}); });
} }

View file

@ -46,7 +46,7 @@
<p class="text-muted mb-2"> <p class="text-muted mb-2">
<span id="username"></span> <span id="username"></span>
</p> </p>
<p id="bio" class="mb-3 preserve-linebreaks"></p> <p id="bio" class="mb-3"></p>
</div> </div>
<div> <div>
<a th:href="@{/profile/edit}" class="btn btn-outline-primary"> <a th:href="@{/profile/edit}" class="btn btn-outline-primary">

View file

@ -4,6 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerImageName;
/** /**
@ -22,6 +23,8 @@ public class TestcontainersConfiguration {
) )
.withDatabaseName("testdb") .withDatabaseName("testdb")
.withUsername("test") .withUsername("test")
.withPassword("test"); .withPassword("test")
.waitingFor(new HostPortWaitStrategy())
.withReuse(true);
} }
} }

View file

@ -1,165 +0,0 @@
package net.javahippie.fitpub.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.service.ActivityImageService;
import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.InboxProcessor;
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.File;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("ActivityPubController Tests")
class ActivityPubControllerTest {
@Mock
private UserRepository userRepository;
@Mock
private ActivityRepository activityRepository;
@Mock
private ActivityImageService activityImageService;
@Mock
private InboxProcessor inboxProcessor;
@Mock
private FollowRepository followRepository;
@Mock
private HttpSignatureValidator signatureValidator;
@Mock
private FederationService federationService;
@Mock
private ObjectMapper objectMapper;
@Mock
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@InjectMocks
private ActivityPubController controller;
private UUID activityId;
private UUID userId;
private Activity activity;
private User user;
private LocalDateTime createdAt;
@BeforeEach
void setUp() {
activityId = UUID.randomUUID();
userId = UUID.randomUUID();
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example");
activity = Activity.builder()
.id(activityId)
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.title("Lunch Run")
.description("Sunny run")
.visibility(Activity.Visibility.PUBLIC)
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.createdAt(createdAt)
.build();
user = User.builder()
.id(userId)
.username("JaneDoe")
.email("janedoe@example.com")
.publicKey("public-key")
.privateKey("private-key")
.build();
}
@Test
@DisplayName("Should serialize activity published timestamp with timezone")
void getActivity_ShouldSerializePublishedTimestampWithTimezone() {
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().get("published"))
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
}
@Test
@DisplayName("Should include workoutData and FitPub context terms in activity note")
void getActivity_ShouldIncludeWorkoutDataAndExtendedContext() {
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
when(workoutDataPayloadBuilder.build(activity)).thenReturn(Map.of(
"activityType", "RUN",
"description", "Sunny run",
"distance", 5000L,
"duration", "PT30M",
"averagePace", "PT6M",
"route", Map.of(
"type", "FeatureCollection",
"features", List.of()
)
));
ResponseEntity<Map<String, Object>> response = controller.getActivity(activityId);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().get("workoutData")).isEqualTo(Map.of(
"activityType", "RUN",
"description", "Sunny run",
"distance", 5000L,
"duration", "PT30M",
"averagePace", "PT6M",
"route", Map.of(
"type", "FeatureCollection",
"features", List.of()
)
));
@SuppressWarnings("unchecked")
List<Object> context = (List<Object>) response.getBody().get("@context");
assertThat(context).hasSize(2);
@SuppressWarnings("unchecked")
Map<String, Object> extensions = (Map<String, Object>) context.get(1);
assertThat(extensions)
.containsEntry("fitpub", "https://fitpub.social/ns#")
.containsEntry("workoutData", "fitpub:workoutData")
.containsEntry("route", "fitpub:route");
}
}

View file

@ -2,25 +2,19 @@ package net.javahippie.fitpub.integration;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import net.javahippie.fitpub.config.TestcontainersConfiguration; import net.javahippie.fitpub.config.TestcontainersConfiguration;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.service.ActivityImageService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import net.javahippie.fitpub.model.entity.Follow; import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.RemoteActor; import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.RemoteActivity;
import net.javahippie.fitpub.model.entity.User; import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.RemoteActivityRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator; import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.security.JwtTokenProvider; import net.javahippie.fitpub.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@ -32,21 +26,15 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.math.BigDecimal;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ -75,12 +63,6 @@ class FederationFollowFlowIntegrationTest {
@Autowired @Autowired
private RemoteActorRepository remoteActorRepository; private RemoteActorRepository remoteActorRepository;
@Autowired
private RemoteActivityRepository remoteActivityRepository;
@Autowired
private ActivityRepository activityRepository;
@Autowired @Autowired
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@ -90,9 +72,6 @@ class FederationFollowFlowIntegrationTest {
@Autowired @Autowired
private HttpSignatureValidator signatureValidator; private HttpSignatureValidator signatureValidator;
@MockBean
private ActivityImageService activityImageService;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
private String baseUrl; private String baseUrl;
@ -122,22 +101,6 @@ class FederationFollowFlowIntegrationTest {
authToken = jwtTokenProvider.createToken(testUser.getUsername()); authToken = jwtTokenProvider.createToken(testUser.getUsername());
} }
private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException {
KeyPair keyPair = generateRsaKeyPair();
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
return userRepository.save(User.builder()
.username(username)
.email(email)
.passwordHash(passwordEncoder.encode("password123"))
.displayName(displayName)
.publicKey(publicKey)
.privateKey(privateKey)
.enabled(true)
.build());
}
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException { private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048); keyGen.initialize(2048);
@ -307,111 +270,6 @@ class FederationFollowFlowIntegrationTest {
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED); assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
} }
@Test
@DisplayName("Should import its own exported public activity through inbox")
void testActivityRoundtripThroughExportAndInbox() throws Exception {
User importingUser = testUser;
User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe");
Activity activity = activityRepository.save(Activity.builder()
.userId(exportingUser.getId())
.activityType(Activity.ActivityType.RUN)
.title("Lunch Run")
.description("Sunny run in the city")
.startedAt(LocalDateTime.of(2026, 5, 2, 12, 0))
.endedAt(LocalDateTime.of(2026, 5, 2, 12, 30))
.createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000))
.visibility(Activity.Visibility.PUBLIC)
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
.sourceFileFormat("FIT")
.published(true)
.build());
String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername();
when(activityImageService.getActivityImageFile(activity.getId()))
.thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image"));
remoteActorRepository.save(RemoteActor.builder()
.actorUri(exportingActorUri)
.username(exportingUser.getUsername())
.domain(java.net.URI.create(baseUrl).getHost())
.displayName(exportingUser.getDisplayName())
.inboxUrl(exportingActorUri + "/inbox")
.outboxUrl(exportingActorUri + "/outbox")
.publicKey(exportingUser.getPublicKey())
.publicKeyId(exportingActorUri + "#main-key")
.lastFetchedAt(Instant.now())
.build());
followRepository.save(Follow.builder()
.followerId(importingUser.getId())
.followingActorUri(exportingActorUri)
.status(Follow.FollowStatus.ACCEPTED)
.activityId(baseUrl + "/activities/follow/" + UUID.randomUUID())
.build());
MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId())
.accept("application/activity+json"))
.andExpect(status().isOk())
.andReturn();
@SuppressWarnings("unchecked")
Map<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
Map<String, Object> createActivity = Map.of(
"@context", "https://www.w3.org/ns/activitystreams",
"type", "Create",
"id", baseUrl + "/activities/create/" + UUID.randomUUID(),
"actor", exportingActorUri,
"object", exportedNote
);
String privateKeyPem = exportingUser.getPrivateKey();
String inboxPath = "/users/" + importingUser.getUsername() + "/inbox";
String inboxUrl = "http://localhost" + inboxPath;
String body = objectMapper.writeValueAsString(createActivity);
HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest(
"POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key"
);
mockMvc.perform(post(inboxPath)
.contentType("application/activity+json")
.header("Host", sigHeaders.host)
.header("Date", sigHeaders.date)
.header("Digest", sigHeaders.digest)
.header("Signature", sigHeaders.signature)
.content(body))
.andExpect(status().isAccepted());
RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
.orElseThrow();
@SuppressWarnings("unchecked")
Map<String, Object> workoutData = (Map<String, Object>) exportedNote.get("workoutData");
assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id"));
assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri);
assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name",
exportedNote.getOrDefault("summary", "Untitled Activity")));
assertThat(imported.getDescription()).isEqualTo(workoutData.get("description"));
assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published")));
assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC);
assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType"));
assertThat(imported.getTotalDistance()).isEqualTo(5000L);
assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L);
assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain"));
assertThat(imported.getAveragePaceSeconds()).isNull();
assertThat(imported.getAverageHeartRate()).isNull();
assertThat(imported.getMaxSpeed()).isNull();
assertThat(imported.getAverageSpeed()).isNull();
assertThat(imported.getCalories()).isNull();
assertThat(imported.getMapImageUrl()).isNull();
assertThat(imported.getTrackGeojsonUrl()).isNull();
assertThat(imported.getSimplifiedTrack()).isNull();
}
@Test @Test
@DisplayName("Should reject inbox POST without HTTP signature with 401") @DisplayName("Should reject inbox POST without HTTP signature with 401")
void testInboxRejectsUnsignedRequest() throws Exception { void testInboxRejectsUnsignedRequest() throws Exception {
@ -452,23 +310,6 @@ class FederationFollowFlowIntegrationTest {
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
private String stripHtml(String html) {
if (html == null) {
return "";
}
return html
.replaceAll("<br\\s*/?>", "\n")
.replaceAll("<p>", "")
.replaceAll("</p>", "\n")
.replaceAll("<[^>]+>", "")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&")
.trim();
}
@Test @Test
@DisplayName("Should process Undo Follow activity and remove follow relationship") @DisplayName("Should process Undo Follow activity and remove follow relationship")
void testProcessUndoFollowActivity() throws Exception { void testProcessUndoFollowActivity() throws Exception {

View file

@ -27,16 +27,12 @@ import static org.junit.jupiter.api.Assertions.*;
/** /**
* Manual test for ActivityImageService. * Manual test for ActivityImageService.
* These tests are disabled by default and should only be run manually. * These tests are disabled by default and should only be run manually.
*
* To run this test manually:
* mvn test -Dtest=ActivityImageServiceTest
*/ */
@SpringBootTest(properties = { @SpringBootTest(properties = {
"fitpub.image.osm-tiles.enabled=true" "fitpub.image.osm-tiles.enabled=true"
}) })
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(TestcontainersConfiguration.class) @Import(TestcontainersConfiguration.class)
@Disabled("Manual test - run explicitly when needed")
class ActivityImageServiceTest { class ActivityImageServiceTest {
@Autowired @Autowired
@ -59,6 +55,7 @@ class ActivityImageServiceTest {
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual * mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
*/ */
@Test @Test
@Disabled("Manual test - run explicitly when needed")
@DisplayName("Generate activity image from test FIT file") @DisplayName("Generate activity image from test FIT file")
void testGenerateActivityImage_Manual() throws Exception { void testGenerateActivityImage_Manual() throws Exception {
// Load test FIT file // Load test FIT file

View file

@ -1,42 +1,25 @@
package net.javahippie.fitpub.service; package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.ActivityMetrics;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.anyBoolean; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** /**
* Unit tests for ActivityPostProcessingService. * Unit tests for ActivityPostProcessingService.
@ -66,9 +49,6 @@ class ActivityPostProcessingServiceTest {
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
@Mock
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@InjectMocks @InjectMocks
private ActivityPostProcessingService service; private ActivityPostProcessingService service;
@ -76,13 +56,11 @@ class ActivityPostProcessingServiceTest {
private UUID userId; private UUID userId;
private Activity testActivity; private Activity testActivity;
private User testUser; private User testUser;
private LocalDateTime createdAt;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
activityId = UUID.randomUUID(); activityId = UUID.randomUUID();
userId = UUID.randomUUID(); userId = UUID.randomUUID();
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
// Set baseUrl via reflection (since it's @Value injected) // Set baseUrl via reflection (since it's @Value injected)
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example"); ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
@ -98,39 +76,9 @@ class ActivityPostProcessingServiceTest {
.totalDistance(BigDecimal.valueOf(5000)) .totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L) .totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100)) .elevationGain(BigDecimal.valueOf(100))
.startedAt(createdAt.minusMinutes(30)) .startedAt(LocalDateTime.now())
.createdAt(createdAt) .createdAt(LocalDateTime.now())
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
new Coordinate(8.55, 47.37),
new Coordinate(8.56, 47.38)
}))
.build(); .build();
testActivity.setMetrics(ActivityMetrics.builder()
.averagePaceSeconds(321L)
.build());
Map<String, Object> workoutData = new HashMap<>();
workoutData.put("activityType", "RUN");
workoutData.put("description", "Morning jog");
workoutData.put("distance", 5000L);
workoutData.put("duration", "PT30M");
workoutData.put("averagePace", "PT5M21S");
workoutData.put("elevationGain", 100);
workoutData.put("route", Map.of(
"type", "FeatureCollection",
"features", List.of(
Map.of(
"type", "Feature",
"geometry", Map.of(
"type", "LineString",
"coordinates", List.of(
List.of(8.55, 47.37),
List.of(8.56, 47.38)
)
)
)
)
));
lenient().when(workoutDataPayloadBuilder.build(testActivity)).thenReturn(workoutData);
// Create test user // Create test user
testUser = User.builder() testUser = User.builder()
@ -284,24 +232,6 @@ class ActivityPostProcessingServiceTest {
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false)); verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
} }
@Test
@DisplayName("Should serialize federation note published timestamp with timezone")
void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() {
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class);
service.publishToFederationAsync(activityId, userId);
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
assertThat(noteCaptor.getValue().get("published"))
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
}
@Test @Test
@DisplayName("Should skip federation for PRIVATE activity") @DisplayName("Should skip federation for PRIVATE activity")
void testPublishToFederationAsync_PrivateActivity() { void testPublishToFederationAsync_PrivateActivity() {
@ -387,47 +317,4 @@ class ActivityPostProcessingServiceTest {
// Then: Verify federation was called (content formatting is tested indirectly) // Then: Verify federation was called (content formatting is tested indirectly)
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
} }
@Test
@DisplayName("Should include workoutData payload in federation note")
void testPublishToFederationAsync_IncludesWorkoutDataPayload() {
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(Map.class);
service.publishToFederationAsync(activityId, userId);
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
@SuppressWarnings("unchecked")
Map<String, Object> workoutData = (Map<String, Object>) noteCaptor.getValue().get("workoutData");
assertThat(workoutData)
.containsEntry("activityType", "RUN")
.containsEntry("description", "Morning jog")
.containsEntry("distance", 5000L)
.containsEntry("duration", "PT30M")
.containsEntry("averagePace", "PT5M21S")
.containsEntry("elevationGain", 100);
@SuppressWarnings("unchecked")
Map<String, Object> route = (Map<String, Object>) workoutData.get("route");
assertThat(route).containsEntry("type", "FeatureCollection");
@SuppressWarnings("unchecked")
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
assertThat(features).hasSize(1);
assertThat(features.get(0)).containsEntry("type", "Feature");
@SuppressWarnings("unchecked")
Map<String, Object> geometry = (Map<String, Object>) features.get(0).get("geometry");
assertThat(geometry).containsEntry("type", "LineString");
assertThat(geometry.get("coordinates")).isEqualTo(List.of(
List.of(8.55, 47.37),
List.of(8.56, 47.38)
));
}
} }

View file

@ -1,217 +0,0 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.RemoteActivity;
import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.CommentRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.LikeRepository;
import net.javahippie.fitpub.repository.RemoteActivityRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.locationtech.jts.geom.LineString;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("InboxProcessor Tests")
class InboxProcessorTest {
@Mock
private UserRepository userRepository;
@Mock
private FollowRepository followRepository;
@Mock
private FederationService federationService;
@Mock
private ActivityRepository activityRepository;
@Mock
private LikeRepository likeRepository;
@Mock
private CommentRepository commentRepository;
@Mock
private NotificationService notificationService;
@Mock
private RemoteActivityRepository remoteActivityRepository;
@Mock
private RemoteActorRepository remoteActorRepository;
@InjectMocks
private InboxProcessor inboxProcessor;
private User localUser;
private String remoteActorUri;
@BeforeEach
void setUp() {
localUser = User.builder()
.id(UUID.randomUUID())
.username("JaneDoe")
.email("janedoe@example.com")
.passwordHash("irrelevant")
.publicKey("public-key")
.privateKey("private-key")
.build();
remoteActorUri = "https://fitpub.example.com/users/JohnDoe";
ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example");
}
@Test
@DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone")
void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() {
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123"))
.thenReturn(false);
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
.actorUri(remoteActorUri)
.username("JohnDoe")
.domain("fitpub.example.com")
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
.publicKey("public-key")
.build());
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
.thenReturn(Optional.of(Follow.builder()
.followerId(localUser.getId())
.followingActorUri(remoteActorUri)
.status(Follow.FollowStatus.ACCEPTED)
.build()));
Map<String, Object> note = Map.of(
"id", "https://fitpub.example.com/activities/123",
"type", "Note",
"name", "Lunch Run",
"content", "<p>Sunny run</p>",
"published", "2026-05-02T09:24:50.921241",
"to", List.of("https://www.w3.org/ns/activitystreams#Public")
);
Map<String, Object> activity = Map.of(
"type", "Create",
"actor", remoteActorUri,
"object", note
);
ArgumentCaptor<net.javahippie.fitpub.model.entity.RemoteActivity> remoteActivityCaptor =
ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class);
inboxProcessor.processActivity("JaneDoe", activity);
verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123");
verify(federationService).fetchRemoteActor(remoteActorUri);
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
assertThat(remoteActivityCaptor.getValue().getPublishedAt())
.isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z"));
}
@Test
@DisplayName("Should prefer workoutData fields over legacy content parsing")
void processCreateRemoteActivity_WithWorkoutDataPayload_ShouldPreferWorkoutDataFields() {
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/456"))
.thenReturn(false);
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
.actorUri(remoteActorUri)
.username("JohnDoe")
.domain("fitpub.example.com")
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
.publicKey("public-key")
.build());
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
.thenReturn(Optional.of(Follow.builder()
.followerId(localUser.getId())
.followingActorUri(remoteActorUri)
.status(Follow.FollowStatus.ACCEPTED)
.build()));
Map<String, Object> workoutData = new HashMap<>();
workoutData.put("activityType", "RUN");
workoutData.put("description", "Direct workoutData description");
workoutData.put("distance", 9800L);
workoutData.put("duration", "PT41M9S");
workoutData.put("averagePace", "PT4M12S");
workoutData.put("elevationGain", 123);
workoutData.put("route", Map.of(
"type", "FeatureCollection",
"features", List.of(Map.of(
"type", "Feature",
"geometry", Map.of(
"type", "LineString",
"coordinates", List.of(
List.of(8.55, 47.37),
List.of(8.56, 47.38),
List.of(8.57, 47.39)
)
)
))
));
Map<String, Object> note = Map.of(
"id", "https://fitpub.example.com/activities/456",
"type", "Note",
"name", "Kraremanns Lauf 2026",
"content", "<p>Kraremanns Lauf 2026</p><p>Run · 9.80 km · 41:09</p><p>Legacy content fallback</p>",
"published", "2026-05-02T09:24:50.921241",
"to", List.of("https://www.w3.org/ns/activitystreams#Public"),
"workoutData", workoutData
);
Map<String, Object> activity = Map.of(
"type", "Create",
"actor", remoteActorUri,
"object", note
);
ArgumentCaptor<RemoteActivity> remoteActivityCaptor =
ArgumentCaptor.forClass(RemoteActivity.class);
inboxProcessor.processActivity("JaneDoe", activity);
verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
RemoteActivity remoteActivity = remoteActivityCaptor.getValue();
assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026");
assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description");
assertThat(remoteActivity.getActivityType()).isEqualTo("RUN");
assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L);
assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L);
assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L);
assertThat(remoteActivity.getElevationGain()).isEqualTo(123);
LineString simplifiedTrack = remoteActivity.getSimplifiedTrack();
assertThat(simplifiedTrack).isNotNull();
assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3);
assertThat(simplifiedTrack.getSRID()).isEqualTo(4326);
assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55);
assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37);
}
}

View file

@ -0,0 +1,203 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Proxy;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.HttpStatus.FORBIDDEN;
@DisplayName("ProfileAccessService Tests")
class ProfileAccessServiceTest {
@Test
@DisplayName("PUBLIC profile should be visible anonymously")
void publicProfileShouldBeVisibleAnonymously() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-public", User.ProfileVisibility.PUBLIC);
assertTrue(service.canViewProfile(owner, null));
assertEquals(0, lookupCount.get());
}
@Test
@DisplayName("PUBLIC profile should be visible to another authenticated user")
void publicProfileShouldBeVisibleToAnotherAuthenticatedUser() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-public-auth", User.ProfileVisibility.PUBLIC);
User viewer = user("viewer-public-auth", User.ProfileVisibility.PUBLIC);
assertTrue(service.canViewProfile(owner, viewer));
assertEquals(0, lookupCount.get());
}
@Test
@DisplayName("FOLLOWERS profile should be forbidden anonymously")
void followersProfileShouldBeForbiddenAnonymously() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-followers-anon", User.ProfileVisibility.FOLLOWERS);
assertFalse(service.canViewProfile(owner, null));
}
@Test
@DisplayName("FOLLOWERS profile should be forbidden to non followers")
void followersProfileShouldBeForbiddenToNonFollowers() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-followers-nonf", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-nonf", User.ProfileVisibility.PUBLIC);
assertFalse(service.canViewProfile(owner, viewer));
assertEquals(1, lookupCount.get());
}
@Test
@DisplayName("FOLLOWERS profile should be visible to accepted followers")
void followersProfileShouldBeVisibleToAcceptedFollowers() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(
Optional.of(Follow.builder().status(Follow.FollowStatus.ACCEPTED).build()),
lookupCount
);
User owner = user("owner-followers-accepted", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-accepted", User.ProfileVisibility.PUBLIC);
assertTrue(service.canViewProfile(owner, viewer));
assertEquals(1, lookupCount.get());
}
@Test
@DisplayName("FOLLOWERS profile should be forbidden to pending followers")
void followersProfileShouldBeForbiddenToPendingFollowers() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(
Optional.of(Follow.builder().status(Follow.FollowStatus.PENDING).build()),
lookupCount
);
User owner = user("owner-followers-pending", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-pending", User.ProfileVisibility.PUBLIC);
assertFalse(service.canViewProfile(owner, viewer));
assertEquals(1, lookupCount.get());
}
@Test
@DisplayName("PRIVATE profile should be forbidden anonymously")
void privateProfileShouldBeForbiddenAnonymously() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-private-anon", User.ProfileVisibility.PRIVATE);
assertFalse(service.canViewProfile(owner, null));
}
@Test
@DisplayName("PRIVATE profile should be forbidden to another authenticated user")
void privateProfileShouldBeForbiddenToAnotherAuthenticatedUser() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-private-other", User.ProfileVisibility.PRIVATE);
User viewer = user("viewer-private-other", User.ProfileVisibility.PUBLIC);
assertFalse(service.canViewProfile(owner, viewer));
assertEquals(0, lookupCount.get());
}
@Test
@DisplayName("Owner should always be able to view own profile")
void ownerShouldAlwaysBeAbleToViewOwnProfile() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
for (User.ProfileVisibility visibility : User.ProfileVisibility.values()) {
User owner = user("self-" + visibility.name().toLowerCase(), visibility);
assertTrue(service.canViewProfile(owner, owner));
}
}
@Test
@DisplayName("Require profile access should throw forbidden with followers message")
void requireProfileAccessShouldThrowForbiddenWithFollowersMessage() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-followers-msg", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-msg", User.ProfileVisibility.PUBLIC);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.requireProfileAccess(owner, viewer)
);
assertEquals(FORBIDDEN, exception.getStatusCode());
assertEquals("This profile is only visible to followers.", exception.getReason());
}
@Test
@DisplayName("Require profile access should throw forbidden with private message")
void requireProfileAccessShouldThrowForbiddenWithPrivateMessage() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-private-msg", User.ProfileVisibility.PRIVATE);
User viewer = user("viewer-private-msg", User.ProfileVisibility.PUBLIC);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.requireProfileAccess(owner, viewer)
);
assertEquals(FORBIDDEN, exception.getStatusCode());
assertEquals("This profile is private.", exception.getReason());
}
@Test
@DisplayName("Require profile access should allow visible profiles")
void requireProfileAccessShouldAllowVisibleProfiles() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-public-visible", User.ProfileVisibility.PUBLIC);
assertDoesNotThrow(() -> service.requireProfileAccess(owner, null));
}
private ProfileAccessService createService(Optional<Follow> followLookupResult, AtomicInteger lookupCount) {
FollowRepository repository = (FollowRepository) Proxy.newProxyInstance(
FollowRepository.class.getClassLoader(),
new Class[]{FollowRepository.class},
(proxy, method, args) -> {
if ("findByFollowerIdAndFollowingActorUri".equals(method.getName())) {
lookupCount.incrementAndGet();
return followLookupResult;
}
throw new UnsupportedOperationException("Unexpected repository method: " + method.getName());
}
);
ProfileAccessService service = new ProfileAccessService(repository);
ReflectionTestUtils.setField(service, "baseUrl", "http://localhost:8080");
return service;
}
private User user(String username, User.ProfileVisibility visibility) {
return User.builder()
.id(UUID.randomUUID())
.username(username)
.email(username + "@example.com")
.passwordHash("hash")
.publicKey("pub")
.privateKey("priv")
.profileVisibility(visibility)
.build();
}
}

View file

@ -1,100 +0,0 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.ActivityMetrics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("WorkoutDataPayloadBuilder Tests")
class WorkoutDataPayloadBuilderTest {
@Mock
private PrivacyZoneService privacyZoneService;
@Mock
private TrackPrivacyFilter trackPrivacyFilter;
@InjectMocks
private WorkoutDataPayloadBuilder builder;
private UUID userId;
private Activity activity;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
activity = Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.description("Morning jog")
.visibility(Activity.Visibility.PUBLIC)
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
new Coordinate(8.55, 47.37),
new Coordinate(8.56, 47.38)
}))
.build();
activity.setMetrics(ActivityMetrics.builder()
.averagePaceSeconds(321L)
.averageHeartRate(150)
.averageSpeed(BigDecimal.valueOf(10.4))
.maxSpeed(BigDecimal.valueOf(14.2))
.calories(420)
.build());
}
@Test
@DisplayName("Should build workoutData payload with route and metrics")
void build_ShouldIncludeWorkoutDataRouteAndMetrics() {
when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of());
Map<String, Object> workoutData = builder.build(activity);
assertThat(workoutData)
.containsEntry("activityType", "RUN")
.containsEntry("description", "Morning jog")
.containsEntry("distance", 5000L)
.containsEntry("duration", "PT30M")
.containsEntry("elevationGain", 100)
.containsEntry("averagePace", "PT5M21S")
.containsEntry("averageHeartRate", 150)
.containsEntry("averageSpeed", 10.4)
.containsEntry("maxSpeed", 14.2)
.containsEntry("calories", 420);
@SuppressWarnings("unchecked")
Map<String, Object> route = (Map<String, Object>) workoutData.get("route");
assertThat(route).containsEntry("type", "FeatureCollection");
@SuppressWarnings("unchecked")
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
assertThat(features).hasSize(1);
@SuppressWarnings("unchecked")
Map<String, Object> geometry = (Map<String, Object>) features.get(0).get("geometry");
assertThat(geometry).containsEntry("type", "LineString");
assertThat(geometry.get("coordinates")).isEqualTo(List.of(
List.of(8.55, 47.37),
List.of(8.56, 47.38)
));
}
}