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