diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index 4cf3717..9702429 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -29,6 +29,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.ZoneOffset; import java.util.*; import java.util.regex.Pattern; @@ -436,7 +437,7 @@ public class ActivityPubController { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", activityUri); diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index 8a582bc..eb0ec99 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.time.ZoneOffset; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -199,7 +200,7 @@ public class ActivityPostProcessingService { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", baseUrl + "/activities/" + activity.getId()); diff --git a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java index 8dff712..d801143 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -21,6 +21,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.UUID; @@ -411,7 +416,7 @@ public class InboxProcessor { // Parse published timestamp String publishedStr = (String) noteObject.get("published"); - Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now(); + Instant publishedAt = parsePublishedAt(publishedStr); // Build RemoteActivity entity RemoteActivity remoteActivity = RemoteActivity.builder() @@ -824,6 +829,44 @@ public class InboxProcessor { } } + /** + * Parse ActivityPub published timestamps. + * + *
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. */ diff --git a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java new file mode 100644 index 0000000..eb52f67 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java @@ -0,0 +1,114 @@ +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 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.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; + + @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