diff --git a/src/main/java/net/javahippie/fitpub/FitPubApplication.java b/src/main/java/net/javahippie/fitpub/FitPubApplication.java index 79756a4..462a721 100644 --- a/src/main/java/net/javahippie/fitpub/FitPubApplication.java +++ b/src/main/java/net/javahippie/fitpub/FitPubApplication.java @@ -10,6 +10,7 @@ import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.core5.util.Timeout; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.scheduling.annotation.EnableAsync; @@ -22,6 +23,7 @@ import org.springframework.web.client.RestTemplate; * through the ActivityPub protocol. */ @SpringBootApplication +@ConfigurationPropertiesScan @EnableAsync @EnableScheduling @Slf4j diff --git a/src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java b/src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java new file mode 100644 index 0000000..978dad6 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java @@ -0,0 +1,41 @@ +package net.javahippie.fitpub.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configurable max-length limits for user and activity text fields. + */ +@Data +@ConfigurationProperties(prefix = "fitpub") +public class FitPubTextLimitsProperties { + + private User user = new User(); + private Activity activity = new Activity(); + + @Data + public static class User { + private Bio bio = new Bio(); + } + + @Data + public static class Bio { + private int maxLength = 500; + } + + @Data + public static class Activity { + private Title title = new Title(); + private Description description = new Description(); + } + + @Data + public static class Title { + private int maxLength = 200; + } + + @Data + public static class Description { + private int maxLength = 5000; + } +} diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java index 4dd7354..6e797e7 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java @@ -1,8 +1,7 @@ package net.javahippie.fitpub.controller; import lombok.RequiredArgsConstructor; -import net.javahippie.fitpub.service.ActivityDescriptionValidationService; -import net.javahippie.fitpub.service.ActivityTitleValidationService; +import net.javahippie.fitpub.service.TextValidationService; import org.springframework.ui.Model; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -17,8 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping; @RequiredArgsConstructor public class ActivitiesViewController { - private final ActivityDescriptionValidationService activityDescriptionValidationService; - private final ActivityTitleValidationService activityTitleValidationService; + private final TextValidationService textValidationService; /** * Show activities list page @@ -33,8 +31,8 @@ public class ActivitiesViewController { */ @GetMapping("/upload") public String uploadActivity(Model model) { - model.addAttribute("activityTitleMaxLength", activityTitleValidationService.getMaxLength()); - model.addAttribute("activityDescriptionMaxLength", activityDescriptionValidationService.getMaxLength()); + model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength()); + model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength()); return "activities/upload"; } @@ -53,8 +51,8 @@ public class ActivitiesViewController { @GetMapping("/{id}/edit") public String editActivity(@PathVariable String id, Model model) { // The activity data will be loaded via JavaScript API calls - model.addAttribute("activityTitleMaxLength", activityTitleValidationService.getMaxLength()); - model.addAttribute("activityDescriptionMaxLength", activityDescriptionValidationService.getMaxLength()); + model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength()); + model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength()); return "activities/edit"; } } diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java index d4573a1..f764bc3 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java @@ -13,16 +13,15 @@ import net.javahippie.fitpub.model.entity.PrivacyZone; import net.javahippie.fitpub.model.entity.User; import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.UserRepository; -import net.javahippie.fitpub.service.ActivityDescriptionValidationService; import net.javahippie.fitpub.service.ActivityFileService; import net.javahippie.fitpub.service.ActivityImageService; import net.javahippie.fitpub.service.ActivityPostProcessingService; -import net.javahippie.fitpub.service.ActivityTitleValidationService; import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.WeatherService; import net.javahippie.fitpub.service.FitFileService; import net.javahippie.fitpub.service.PrivacyZoneService; import net.javahippie.fitpub.service.ReactionEnricher; +import net.javahippie.fitpub.service.TextValidationService; import net.javahippie.fitpub.service.TrackPrivacyFilter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -50,8 +49,7 @@ import java.util.UUID; @Slf4j public class ActivityController { - private final ActivityDescriptionValidationService activityDescriptionValidationService; - private final ActivityTitleValidationService activityTitleValidationService; + private final TextValidationService textValidationService; private final ActivityFileService activityFileService; private final FitFileService fitFileService; private final UserRepository userRepository; @@ -157,8 +155,8 @@ public class ActivityController { ) { log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename()); - activityTitleValidationService.validate(request.getTitle()); - activityDescriptionValidationService.validate(request.getDescription()); + textValidationService.validateActivityTitle(request.getTitle()); + textValidationService.validateActivityDescription(request.getDescription()); User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); @@ -318,8 +316,8 @@ public class ActivityController { UUID userId = getUserId(userDetails); - activityTitleValidationService.validate(request.getTitle()); - activityDescriptionValidationService.validate(request.getDescription()); + textValidationService.validateActivityTitle(request.getTitle()); + textValidationService.validateActivityDescription(request.getDescription()); try { Activity updated = fitFileService.updateActivity( diff --git a/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java b/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java index 47d60b6..a3dcbee 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java @@ -1,7 +1,7 @@ package net.javahippie.fitpub.controller; import lombok.RequiredArgsConstructor; -import net.javahippie.fitpub.service.UserBioValidationService; +import net.javahippie.fitpub.service.TextValidationService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.PathVariable; @RequiredArgsConstructor public class ProfileViewController { - private final UserBioValidationService userBioValidationService; + private final TextValidationService textValidationService; /** * Current user's profile page. @@ -39,7 +39,7 @@ public class ProfileViewController { @GetMapping("/profile/edit") public String editProfile(Model model) { model.addAttribute("pageTitle", "Edit Profile"); - model.addAttribute("bioMaxLength", userBioValidationService.getMaxLength()); + model.addAttribute("bioMaxLength", textValidationService.getUserBioMaxLength()); return "profile/edit"; } diff --git a/src/main/java/net/javahippie/fitpub/controller/UserController.java b/src/main/java/net/javahippie/fitpub/controller/UserController.java index 55eb77e..e64023c 100644 --- a/src/main/java/net/javahippie/fitpub/controller/UserController.java +++ b/src/main/java/net/javahippie/fitpub/controller/UserController.java @@ -14,8 +14,8 @@ import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.service.FederationService; +import net.javahippie.fitpub.service.TextValidationService; import net.javahippie.fitpub.service.WebFingerClient; -import net.javahippie.fitpub.service.UserBioValidationService; import net.javahippie.fitpub.service.UserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; @@ -48,7 +48,7 @@ public class UserController { private final WebFingerClient webFingerClient; private final FederationService federationService; private final UserService userService; - private final UserBioValidationService userBioValidationService; + private final TextValidationService textValidationService; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; @Value("${fitpub.base-url}") @@ -103,7 +103,7 @@ public class UserController { ) { log.info("User {} updating profile", userDetails.getUsername()); - userBioValidationService.validate(request.getBio()); + textValidationService.validateUserBio(request.getBio()); User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); diff --git a/src/main/java/net/javahippie/fitpub/service/TextValidationService.java b/src/main/java/net/javahippie/fitpub/service/TextValidationService.java new file mode 100644 index 0000000..8f79eeb --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/TextValidationService.java @@ -0,0 +1,46 @@ +package net.javahippie.fitpub.service; + +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.config.FitPubTextLimitsProperties; +import net.javahippie.fitpub.exception.ApiValidationException; +import org.springframework.stereotype.Service; + +/** + * Central validation policy for configurable text fields. + */ +@Service +@RequiredArgsConstructor +public class TextValidationService { + + private final FitPubTextLimitsProperties textLimitsProperties; + + public int getUserBioMaxLength() { + return textLimitsProperties.getUser().getBio().getMaxLength(); + } + + public int getActivityTitleMaxLength() { + return textLimitsProperties.getActivity().getTitle().getMaxLength(); + } + + public int getActivityDescriptionMaxLength() { + return textLimitsProperties.getActivity().getDescription().getMaxLength(); + } + + public void validateUserBio(String bio) { + validateMaxLength(bio, getUserBioMaxLength(), "Bio"); + } + + public void validateActivityTitle(String title) { + validateMaxLength(title, getActivityTitleMaxLength(), "Title"); + } + + public void validateActivityDescription(String description) { + validateMaxLength(description, getActivityDescriptionMaxLength(), "Description"); + } + + private void validateMaxLength(String value, int maxLength, String fieldName) { + if (value != null && value.length() > maxLength) { + throw new ApiValidationException(fieldName + " must not exceed " + maxLength + " characters"); + } + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java new file mode 100644 index 0000000..d4cf8b0 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java @@ -0,0 +1,69 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.config.FitPubTextLimitsProperties; +import net.javahippie.fitpub.exception.ApiValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("TextValidationService Tests") +class TextValidationServiceTest { + + @Test + @DisplayName("Should allow user bio up to configured max length") + void shouldAllowUserBioUpToConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 20, 30)); + + assertDoesNotThrow(() -> service.validateUserBio("123456789012")); + } + + @Test + @DisplayName("Should reject user bio longer than configured max length") + void shouldRejectUserBioLongerThanConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 20, 30)); + + assertThrows(ApiValidationException.class, () -> service.validateUserBio("1234567890123")); + } + + @Test + @DisplayName("Should allow activity title up to configured max length") + void shouldAllowActivityTitleUpToConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 30)); + + assertDoesNotThrow(() -> service.validateActivityTitle("1234567890")); + } + + @Test + @DisplayName("Should reject activity title longer than configured max length") + void shouldRejectActivityTitleLongerThanConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 30)); + + assertThrows(ApiValidationException.class, () -> service.validateActivityTitle("12345678901")); + } + + @Test + @DisplayName("Should allow activity description up to configured max length") + void shouldAllowActivityDescriptionUpToConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 20)); + + assertDoesNotThrow(() -> service.validateActivityDescription("12345678901234567890")); + } + + @Test + @DisplayName("Should reject activity description longer than configured max length") + void shouldRejectActivityDescriptionLongerThanConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 20)); + + assertThrows(ApiValidationException.class, () -> service.validateActivityDescription("123456789012345678901")); + } + + private FitPubTextLimitsProperties properties(int bioMaxLength, int titleMaxLength, int descriptionMaxLength) { + FitPubTextLimitsProperties properties = new FitPubTextLimitsProperties(); + properties.getUser().getBio().setMaxLength(bioMaxLength); + properties.getActivity().getTitle().setMaxLength(titleMaxLength); + properties.getActivity().getDescription().setMaxLength(descriptionMaxLength); + return properties; + } +}