diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java index 359f652..4dd7354 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java @@ -1,5 +1,9 @@ package net.javahippie.fitpub.controller; +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.service.ActivityDescriptionValidationService; +import net.javahippie.fitpub.service.ActivityTitleValidationService; +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 +14,12 @@ import org.springframework.web.bind.annotation.RequestMapping; */ @Controller @RequestMapping("/activities") +@RequiredArgsConstructor public class ActivitiesViewController { + private final ActivityDescriptionValidationService activityDescriptionValidationService; + private final ActivityTitleValidationService activityTitleValidationService; + /** * Show activities list page */ @@ -24,7 +32,9 @@ public class ActivitiesViewController { * Show activity upload page */ @GetMapping("/upload") - public String uploadActivity() { + public String uploadActivity(Model model) { + model.addAttribute("activityTitleMaxLength", activityTitleValidationService.getMaxLength()); + model.addAttribute("activityDescriptionMaxLength", activityDescriptionValidationService.getMaxLength()); return "activities/upload"; } @@ -41,8 +51,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", activityTitleValidationService.getMaxLength()); + model.addAttribute("activityDescriptionMaxLength", activityDescriptionValidationService.getMaxLength()); 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..d4573a1 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java @@ -13,9 +13,11 @@ 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; @@ -48,6 +50,8 @@ import java.util.UUID; @Slf4j public class ActivityController { + private final ActivityDescriptionValidationService activityDescriptionValidationService; + private final ActivityTitleValidationService activityTitleValidationService; private final ActivityFileService activityFileService; private final FitFileService fitFileService; private final UserRepository userRepository; @@ -153,6 +157,9 @@ public class ActivityController { ) { log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename()); + activityTitleValidationService.validate(request.getTitle()); + activityDescriptionValidationService.validate(request.getDescription()); + User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); @@ -311,6 +318,9 @@ public class ActivityController { UUID userId = getUserId(userDetails); + activityTitleValidationService.validate(request.getTitle()); + activityDescriptionValidationService.validate(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..47d60b6 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.UserBioValidationService; 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 UserBioValidationService userBioValidationService; + /** * 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", userBioValidationService.getMaxLength()); 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..55eb77e 100644 --- a/src/main/java/net/javahippie/fitpub/controller/UserController.java +++ b/src/main/java/net/javahippie/fitpub/controller/UserController.java @@ -15,6 +15,7 @@ import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.service.FederationService; 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; @@ -47,6 +48,7 @@ public class UserController { private final WebFingerClient webFingerClient; private final FederationService federationService; private final UserService userService; + private final UserBioValidationService userBioValidationService; 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()); + userBioValidationService.validate(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/ActivityDescriptionValidationService.java b/src/main/java/net/javahippie/fitpub/service/ActivityDescriptionValidationService.java new file mode 100644 index 0000000..2dd3099 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/ActivityDescriptionValidationService.java @@ -0,0 +1,32 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.exception.ApiValidationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Central validation policy for activity descriptions. + */ +@Service +public class ActivityDescriptionValidationService { + + private final int maxLength; + + public ActivityDescriptionValidationService( + @Value("${fitpub.activity.description.max-length:5000}") int maxLength + ) { + this.maxLength = maxLength; + } + + public int getMaxLength() { + return maxLength; + } + + public void validate(String description) { + if (description != null && description.length() > maxLength) { + throw new ApiValidationException( + "Description must not exceed " + maxLength + " characters" + ); + } + } +} diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityTitleValidationService.java b/src/main/java/net/javahippie/fitpub/service/ActivityTitleValidationService.java new file mode 100644 index 0000000..460a30e --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/ActivityTitleValidationService.java @@ -0,0 +1,32 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.exception.ApiValidationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Central validation policy for activity titles. + */ +@Service +public class ActivityTitleValidationService { + + private final int maxLength; + + public ActivityTitleValidationService( + @Value("${fitpub.activity.title.max-length:200}") int maxLength + ) { + this.maxLength = maxLength; + } + + public int getMaxLength() { + return maxLength; + } + + public void validate(String title) { + if (title != null && title.length() > maxLength) { + throw new ApiValidationException( + "Title must not exceed " + maxLength + " characters" + ); + } + } +} diff --git a/src/main/java/net/javahippie/fitpub/service/UserBioValidationService.java b/src/main/java/net/javahippie/fitpub/service/UserBioValidationService.java new file mode 100644 index 0000000..1fe1672 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/UserBioValidationService.java @@ -0,0 +1,28 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.exception.ApiValidationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Central validation policy for user bio fields. + */ +@Service +public class UserBioValidationService { + + private final int maxLength; + + public UserBioValidationService(@Value("${fitpub.user.bio.max-length:500}") int maxLength) { + this.maxLength = maxLength; + } + + public int getMaxLength() { + return maxLength; + } + + public void validate(String bio) { + if (bio != null && bio.length() > maxLength) { + throw new ApiValidationException("Bio 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/ActivityDescriptionValidationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityDescriptionValidationServiceTest.java new file mode 100644 index 0000000..f0e056b --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivityDescriptionValidationServiceTest.java @@ -0,0 +1,28 @@ +package net.javahippie.fitpub.service; + +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("ActivityDescriptionValidationService Tests") +class ActivityDescriptionValidationServiceTest { + + @Test + @DisplayName("Should allow descriptions up to configured max length") + void shouldAllowDescriptionsUpToConfiguredMaxLength() { + ActivityDescriptionValidationService service = new ActivityDescriptionValidationService(20); + + assertDoesNotThrow(() -> service.validate("12345678901234567890")); + } + + @Test + @DisplayName("Should reject descriptions longer than configured max length") + void shouldRejectDescriptionsLongerThanConfiguredMaxLength() { + ActivityDescriptionValidationService service = new ActivityDescriptionValidationService(20); + + assertThrows(ApiValidationException.class, () -> service.validate("123456789012345678901")); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityTitleValidationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityTitleValidationServiceTest.java new file mode 100644 index 0000000..4263610 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivityTitleValidationServiceTest.java @@ -0,0 +1,28 @@ +package net.javahippie.fitpub.service; + +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("ActivityTitleValidationService Tests") +class ActivityTitleValidationServiceTest { + + @Test + @DisplayName("Should allow titles up to configured max length") + void shouldAllowTitlesUpToConfiguredMaxLength() { + ActivityTitleValidationService service = new ActivityTitleValidationService(10); + + assertDoesNotThrow(() -> service.validate("1234567890")); + } + + @Test + @DisplayName("Should reject titles longer than configured max length") + void shouldRejectTitlesLongerThanConfiguredMaxLength() { + ActivityTitleValidationService service = new ActivityTitleValidationService(10); + + assertThrows(ApiValidationException.class, () -> service.validate("12345678901")); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/UserBioValidationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/UserBioValidationServiceTest.java new file mode 100644 index 0000000..dcf8628 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/UserBioValidationServiceTest.java @@ -0,0 +1,28 @@ +package net.javahippie.fitpub.service; + +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("UserBioValidationService Tests") +class UserBioValidationServiceTest { + + @Test + @DisplayName("Should allow bios up to configured max length") + void shouldAllowBiosUpToConfiguredMaxLength() { + UserBioValidationService service = new UserBioValidationService(12); + + assertDoesNotThrow(() -> service.validate("123456789012")); + } + + @Test + @DisplayName("Should reject bios longer than configured max length") + void shouldRejectBiosLongerThanConfiguredMaxLength() { + UserBioValidationService service = new UserBioValidationService(12); + + assertThrows(ApiValidationException.class, () -> service.validate("1234567890123")); + } +}