From c05ac79d758a23e238ba9d6fbc717e93ea7e3394 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Fri, 1 May 2026 11:53:21 +0200 Subject: [PATCH] feat(config): make profile and activity text length limits configurable Signed-off-by: Marcus Fihlon --- .../javahippie/fitpub/FitPubApplication.java | 2 + .../config/FitPubTextLimitsProperties.java | 41 +++++++++++ .../controller/ActivitiesViewController.java | 14 +++- .../fitpub/controller/ActivityController.java | 8 +++ .../controller/ApiExceptionHandler.java | 19 +++++ .../controller/ProfileViewController.java | 6 ++ .../fitpub/controller/UserController.java | 4 ++ .../exception/ApiValidationException.java | 11 +++ .../model/dto/ActivityUpdateRequest.java | 3 - .../model/dto/ActivityUploadRequest.java | 4 -- .../javahippie/fitpub/model/dto/ApiError.java | 13 ++++ .../fitpub/model/dto/UserUpdateRequest.java | 1 - .../fitpub/service/TextValidationService.java | 46 +++++++++++++ src/main/resources/application.yml | 12 ++++ .../resources/templates/activities/edit.html | 6 +- .../templates/activities/upload.html | 6 +- .../resources/templates/profile/edit.html | 5 +- .../service/TextValidationServiceTest.java | 69 +++++++++++++++++++ 18 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java create mode 100644 src/main/java/net/javahippie/fitpub/controller/ApiExceptionHandler.java create mode 100644 src/main/java/net/javahippie/fitpub/exception/ApiValidationException.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/ApiError.java create mode 100644 src/main/java/net/javahippie/fitpub/service/TextValidationService.java create mode 100644 src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java 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 359f652..6e797e7 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java @@ -1,5 +1,8 @@ package net.javahippie.fitpub.controller; +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.service.TextValidationService; +import org.springframework.ui.Model; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -10,8 +13,11 @@ import org.springframework.web.bind.annotation.RequestMapping; */ @Controller @RequestMapping("/activities") +@RequiredArgsConstructor public class ActivitiesViewController { + private final TextValidationService textValidationService; + /** * Show activities list page */ @@ -24,7 +30,9 @@ public class ActivitiesViewController { * Show activity upload page */ @GetMapping("/upload") - public String uploadActivity() { + public String uploadActivity(Model model) { + model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength()); + model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength()); return "activities/upload"; } @@ -41,8 +49,10 @@ public class ActivitiesViewController { * Show activity edit page */ @GetMapping("/{id}/edit") - public String editActivity(@PathVariable String id) { + public String editActivity(@PathVariable String id, Model model) { // The activity data will be loaded via JavaScript API calls + 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 f6181f7..f764bc3 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java @@ -21,6 +21,7 @@ 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; @@ -48,6 +49,7 @@ import java.util.UUID; @Slf4j public class ActivityController { + private final TextValidationService textValidationService; private final ActivityFileService activityFileService; private final FitFileService fitFileService; private final UserRepository userRepository; @@ -153,6 +155,9 @@ public class ActivityController { ) { log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename()); + textValidationService.validateActivityTitle(request.getTitle()); + textValidationService.validateActivityDescription(request.getDescription()); + User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); @@ -311,6 +316,9 @@ public class ActivityController { UUID userId = getUserId(userDetails); + textValidationService.validateActivityTitle(request.getTitle()); + textValidationService.validateActivityDescription(request.getDescription()); + try { Activity updated = fitFileService.updateActivity( id, diff --git a/src/main/java/net/javahippie/fitpub/controller/ApiExceptionHandler.java b/src/main/java/net/javahippie/fitpub/controller/ApiExceptionHandler.java new file mode 100644 index 0000000..70aeffc --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/ApiExceptionHandler.java @@ -0,0 +1,19 @@ +package net.javahippie.fitpub.controller; + +import net.javahippie.fitpub.exception.ApiValidationException; +import net.javahippie.fitpub.model.dto.ApiError; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Central exception mapping for REST APIs. + */ +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(ApiValidationException.class) + public ResponseEntity handleApiValidation(ApiValidationException e) { + return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage())); + } +} diff --git a/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java b/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java index d39ef1e..a3dcbee 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java @@ -1,5 +1,7 @@ package net.javahippie.fitpub.controller; +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.service.TextValidationService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -9,8 +11,11 @@ import org.springframework.web.bind.annotation.PathVariable; * Controller for user profile view pages. */ @Controller +@RequiredArgsConstructor public class ProfileViewController { + private final TextValidationService textValidationService; + /** * Current user's profile page. * Shows own profile with edit capabilities. @@ -34,6 +39,7 @@ public class ProfileViewController { @GetMapping("/profile/edit") public String editProfile(Model model) { model.addAttribute("pageTitle", "Edit Profile"); + 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 432c035..e64023c 100644 --- a/src/main/java/net/javahippie/fitpub/controller/UserController.java +++ b/src/main/java/net/javahippie/fitpub/controller/UserController.java @@ -14,6 +14,7 @@ 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.UserService; import org.springframework.beans.factory.annotation.Value; @@ -47,6 +48,7 @@ public class UserController { private final WebFingerClient webFingerClient; private final FederationService federationService; private final UserService userService; + private final TextValidationService textValidationService; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; @Value("${fitpub.base-url}") @@ -101,6 +103,8 @@ public class UserController { ) { log.info("User {} updating profile", userDetails.getUsername()); + 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/exception/ApiValidationException.java b/src/main/java/net/javahippie/fitpub/exception/ApiValidationException.java new file mode 100644 index 0000000..3f8229d --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/exception/ApiValidationException.java @@ -0,0 +1,11 @@ +package net.javahippie.fitpub.exception; + +/** + * Exception for API request validation that depends on runtime configuration. + */ +public class ApiValidationException extends RuntimeException { + + public ApiValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java index 6798064..ae215ff 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java @@ -1,7 +1,6 @@ package net.javahippie.fitpub.model.dto; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -18,10 +17,8 @@ import net.javahippie.fitpub.model.entity.Activity; public class ActivityUpdateRequest { @NotNull(message = "Title is required") - @Size(min = 1, max = 200, message = "Title must be between 1 and 200 characters") private String title; - @Size(max = 5000, message = "Description must not exceed 5000 characters") private String description; @NotNull(message = "Visibility is required") diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java index e271d52..7f0779d 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java @@ -1,6 +1,4 @@ package net.javahippie.fitpub.model.dto; - -import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,10 +15,8 @@ import net.javahippie.fitpub.model.entity.Activity; @AllArgsConstructor public class ActivityUploadRequest { - @Size(max = 200, message = "Title must not exceed 200 characters") private String title; - @Size(max = 5000, message = "Description must not exceed 5000 characters") private String description; private Activity.Visibility visibility; diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ApiError.java b/src/main/java/net/javahippie/fitpub/model/dto/ApiError.java new file mode 100644 index 0000000..31cef10 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/ApiError.java @@ -0,0 +1,13 @@ +package net.javahippie.fitpub.model.dto; + +import lombok.Value; + +/** + * Standard error payload for API responses. + */ +@Value +public class ApiError { + + String code; + String message; +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java index 8f4c812..13fb1f7 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java @@ -21,7 +21,6 @@ public class UserUpdateRequest { @Size(max = 100, message = "Display name must not exceed 100 characters") private String displayName; - @Size(max = 500, message = "Bio must not exceed 500 characters") private String bio; @URL(message = "Avatar URL must be a valid URL") 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/main/resources/application.yml b/src/main/resources/application.yml index 3a4e4e5..a1129a3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -84,6 +84,18 @@ fitpub: # Leave empty to allow open registration password: ${REGISTRATION_PASSWORD:} +# User settings + user: + bio: + max-length: ${FITPUB_USER_BIO_MAX_LENGTH:500} + +# Activity settings + activity: + title: + max-length: ${FITPUB_ACTIVITY_TITLE_MAX_LENGTH:200} + description: + max-length: ${FITPUB_ACTIVITY_DESCRIPTION_MAX_LENGTH:5000} + # Storage settings storage: fit-files: diff --git a/src/main/resources/templates/activities/edit.html b/src/main/resources/templates/activities/edit.html index fb97558..5858c3d 100644 --- a/src/main/resources/templates/activities/edit.html +++ b/src/main/resources/templates/activities/edit.html @@ -51,7 +51,7 @@ id="title" name="title" placeholder="e.g., Morning Run" - maxlength="200" + th:attr="maxlength=${activityTitleMaxLength}" required>
Please provide a title for your activity. @@ -83,9 +83,9 @@ name="description" rows="4" placeholder="Share details about your activity..." - maxlength="5000"> + th:attr="maxlength=${activityDescriptionMaxLength}">
- 0/5000 characters + 0/5000 characters
diff --git a/src/main/resources/templates/activities/upload.html b/src/main/resources/templates/activities/upload.html index b45774f..eab2902 100644 --- a/src/main/resources/templates/activities/upload.html +++ b/src/main/resources/templates/activities/upload.html @@ -83,7 +83,7 @@ id="title" name="title" placeholder="e.g., Morning Run" - maxlength="200"> + th:attr="maxlength=${activityTitleMaxLength}">
Please provide a title for your activity.
@@ -126,9 +126,9 @@ name="description" rows="4" placeholder="Share details about your activity..." - maxlength="5000"> + th:attr="maxlength=${activityDescriptionMaxLength}">
- 0/5000 characters + 0/5000 characters
diff --git a/src/main/resources/templates/profile/edit.html b/src/main/resources/templates/profile/edit.html index e7fc0dc..617286a 100644 --- a/src/main/resources/templates/profile/edit.html +++ b/src/main/resources/templates/profile/edit.html @@ -39,9 +39,10 @@
- +
- 0/500 characters + 0/500 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; + } +}