Merge branch 'feat/configure-input-length' into sattelgeschichten

This commit is contained in:
Marcus Fihlon 2026-05-01 11:53:50 +02:00
commit aa4078bb82
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
20 changed files with 274 additions and 18 deletions

View file

@ -1,5 +1,9 @@
package net.javahippie.fitpub.controller; 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.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -10,8 +14,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
*/ */
@Controller @Controller
@RequestMapping("/activities") @RequestMapping("/activities")
@RequiredArgsConstructor
public class ActivitiesViewController { public class ActivitiesViewController {
private final ActivityDescriptionValidationService activityDescriptionValidationService;
private final ActivityTitleValidationService activityTitleValidationService;
/** /**
* Show activities list page * Show activities list page
*/ */
@ -24,7 +32,9 @@ public class ActivitiesViewController {
* Show activity upload page * Show activity upload page
*/ */
@GetMapping("/upload") @GetMapping("/upload")
public String uploadActivity() { public String uploadActivity(Model model) {
model.addAttribute("activityTitleMaxLength", activityTitleValidationService.getMaxLength());
model.addAttribute("activityDescriptionMaxLength", activityDescriptionValidationService.getMaxLength());
return "activities/upload"; return "activities/upload";
} }
@ -41,8 +51,10 @@ public class ActivitiesViewController {
* Show activity edit page * Show activity edit page
*/ */
@GetMapping("/{id}/edit") @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 // The activity data will be loaded via JavaScript API calls
model.addAttribute("activityTitleMaxLength", activityTitleValidationService.getMaxLength());
model.addAttribute("activityDescriptionMaxLength", activityDescriptionValidationService.getMaxLength());
return "activities/edit"; return "activities/edit";
} }
} }

View file

@ -13,9 +13,11 @@ import net.javahippie.fitpub.model.entity.PrivacyZone;
import net.javahippie.fitpub.model.entity.User; import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.service.ActivityDescriptionValidationService;
import net.javahippie.fitpub.service.ActivityFileService; import net.javahippie.fitpub.service.ActivityFileService;
import net.javahippie.fitpub.service.ActivityImageService; import net.javahippie.fitpub.service.ActivityImageService;
import net.javahippie.fitpub.service.ActivityPostProcessingService; import net.javahippie.fitpub.service.ActivityPostProcessingService;
import net.javahippie.fitpub.service.ActivityTitleValidationService;
import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.WeatherService; import net.javahippie.fitpub.service.WeatherService;
import net.javahippie.fitpub.service.FitFileService; import net.javahippie.fitpub.service.FitFileService;
@ -48,6 +50,8 @@ import java.util.UUID;
@Slf4j @Slf4j
public class ActivityController { public class ActivityController {
private final ActivityDescriptionValidationService activityDescriptionValidationService;
private final ActivityTitleValidationService activityTitleValidationService;
private final ActivityFileService activityFileService; private final ActivityFileService activityFileService;
private final FitFileService fitFileService; private final FitFileService fitFileService;
private final UserRepository userRepository; private final UserRepository userRepository;
@ -153,6 +157,9 @@ public class ActivityController {
) { ) {
log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename()); log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename());
activityTitleValidationService.validate(request.getTitle());
activityDescriptionValidationService.validate(request.getDescription());
User user = userRepository.findByUsername(userDetails.getUsername()) User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found")); .orElseThrow(() -> new UsernameNotFoundException("User not found"));
@ -311,6 +318,9 @@ public class ActivityController {
UUID userId = getUserId(userDetails); UUID userId = getUserId(userDetails);
activityTitleValidationService.validate(request.getTitle());
activityDescriptionValidationService.validate(request.getDescription());
try { try {
Activity updated = fitFileService.updateActivity( Activity updated = fitFileService.updateActivity(
id, id,

View file

@ -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<ApiError> handleApiValidation(ApiValidationException e) {
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage()));
}
}

View file

@ -1,5 +1,7 @@
package net.javahippie.fitpub.controller; package net.javahippie.fitpub.controller;
import lombok.RequiredArgsConstructor;
import net.javahippie.fitpub.service.UserBioValidationService;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; 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 for user profile view pages.
*/ */
@Controller @Controller
@RequiredArgsConstructor
public class ProfileViewController { public class ProfileViewController {
private final UserBioValidationService userBioValidationService;
/** /**
* Current user's profile page. * Current user's profile page.
* Shows own profile with edit capabilities. * Shows own profile with edit capabilities.
@ -34,6 +39,7 @@ public class ProfileViewController {
@GetMapping("/profile/edit") @GetMapping("/profile/edit")
public String editProfile(Model model) { public String editProfile(Model model) {
model.addAttribute("pageTitle", "Edit Profile"); model.addAttribute("pageTitle", "Edit Profile");
model.addAttribute("bioMaxLength", userBioValidationService.getMaxLength());
return "profile/edit"; return "profile/edit";
} }

View file

@ -15,6 +15,7 @@ import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.WebFingerClient; import net.javahippie.fitpub.service.WebFingerClient;
import net.javahippie.fitpub.service.UserBioValidationService;
import net.javahippie.fitpub.service.UserService; import net.javahippie.fitpub.service.UserService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -47,6 +48,7 @@ public class UserController {
private final WebFingerClient webFingerClient; private final WebFingerClient webFingerClient;
private final FederationService federationService; private final FederationService federationService;
private final UserService userService; private final UserService userService;
private final UserBioValidationService userBioValidationService;
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
@Value("${fitpub.base-url}") @Value("${fitpub.base-url}")
@ -101,6 +103,8 @@ public class UserController {
) { ) {
log.info("User {} updating profile", userDetails.getUsername()); log.info("User {} updating profile", userDetails.getUsername());
userBioValidationService.validate(request.getBio());
User user = userRepository.findByUsername(userDetails.getUsername()) User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found")); .orElseThrow(() -> new UsernameNotFoundException("User not found"));

View file

@ -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);
}
}

View file

@ -1,7 +1,6 @@
package net.javahippie.fitpub.model.dto; package net.javahippie.fitpub.model.dto;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@ -18,10 +17,8 @@ import net.javahippie.fitpub.model.entity.Activity;
public class ActivityUpdateRequest { public class ActivityUpdateRequest {
@NotNull(message = "Title is required") @NotNull(message = "Title is required")
@Size(min = 1, max = 200, message = "Title must be between 1 and 200 characters")
private String title; private String title;
@Size(max = 5000, message = "Description must not exceed 5000 characters")
private String description; private String description;
@NotNull(message = "Visibility is required") @NotNull(message = "Visibility is required")

View file

@ -1,6 +1,4 @@
package net.javahippie.fitpub.model.dto; package net.javahippie.fitpub.model.dto;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@ -17,10 +15,8 @@ import net.javahippie.fitpub.model.entity.Activity;
@AllArgsConstructor @AllArgsConstructor
public class ActivityUploadRequest { public class ActivityUploadRequest {
@Size(max = 200, message = "Title must not exceed 200 characters")
private String title; private String title;
@Size(max = 5000, message = "Description must not exceed 5000 characters")
private String description; private String description;
private Activity.Visibility visibility; private Activity.Visibility visibility;

View file

@ -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;
}

View file

@ -21,7 +21,6 @@ public class UserUpdateRequest {
@Size(max = 100, message = "Display name must not exceed 100 characters") @Size(max = 100, message = "Display name must not exceed 100 characters")
private String displayName; private String displayName;
@Size(max = 500, message = "Bio must not exceed 500 characters")
private String bio; private String bio;
@URL(message = "Avatar URL must be a valid URL") @URL(message = "Avatar URL must be a valid URL")

View file

@ -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"
);
}
}
}

View file

@ -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"
);
}
}
}

View file

@ -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");
}
}
}

View file

@ -84,6 +84,18 @@ fitpub:
# Leave empty to allow open registration # Leave empty to allow open registration
password: ${REGISTRATION_PASSWORD:} 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 settings
storage: storage:
fit-files: fit-files:

View file

@ -51,7 +51,7 @@
id="title" id="title"
name="title" name="title"
placeholder="e.g., Morning Run" placeholder="e.g., Morning Run"
maxlength="200" th:attr="maxlength=${activityTitleMaxLength}"
required> required>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please provide a title for your activity. Please provide a title for your activity.
@ -83,9 +83,9 @@
name="description" name="description"
rows="4" rows="4"
placeholder="Share details about your activity..." placeholder="Share details about your activity..."
maxlength="5000"></textarea> th:attr="maxlength=${activityDescriptionMaxLength}"></textarea>
<div class="form-text"> <div class="form-text">
<span id="descCharCount">0</span>/5000 characters <span id="descCharCount">0</span>/<span th:text="${activityDescriptionMaxLength}">5000</span> characters
</div> </div>
</div> </div>

View file

@ -83,7 +83,7 @@
id="title" id="title"
name="title" name="title"
placeholder="e.g., Morning Run" placeholder="e.g., Morning Run"
maxlength="200"> th:attr="maxlength=${activityTitleMaxLength}">
<div class="invalid-feedback"> <div class="invalid-feedback">
Please provide a title for your activity. Please provide a title for your activity.
</div> </div>
@ -126,9 +126,9 @@
name="description" name="description"
rows="4" rows="4"
placeholder="Share details about your activity..." placeholder="Share details about your activity..."
maxlength="5000"></textarea> th:attr="maxlength=${activityDescriptionMaxLength}"></textarea>
<div class="form-text"> <div class="form-text">
<span id="descCharCount">0</span>/5000 characters <span id="descCharCount">0</span>/<span th:text="${activityDescriptionMaxLength}">5000</span> characters
</div> </div>
</div> </div>

View file

@ -39,9 +39,10 @@
<!-- Bio --> <!-- Bio -->
<div class="mb-3"> <div class="mb-3">
<label for="bio" class="form-label">Bio</label> <label for="bio" class="form-label">Bio</label>
<textarea class="form-control" id="bio" name="bio" rows="4" maxlength="500"></textarea> <textarea class="form-control" id="bio" name="bio" rows="4"
th:attr="maxlength=${bioMaxLength}"></textarea>
<div class="form-text"> <div class="form-text">
<span id="bioCharCount">0</span>/500 characters <span id="bioCharCount">0</span>/<span th:text="${bioMaxLength}">500</span> characters
</div> </div>
</div> </div>

View file

@ -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"));
}
}

View file

@ -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"));
}
}

View file

@ -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"));
}
}