From 17c2044eb517e61f73ef8d7610b5796ec24efc22 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Mon, 10 Aug 2026 00:55:19 +0300 Subject: [PATCH 1/4] fix(api): map API errors without exposing internals --- src/main/java/goodroad/api/ApiErrors.java | 93 ++++++++++++++++++++--- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/src/main/java/goodroad/api/ApiErrors.java b/src/main/java/goodroad/api/ApiErrors.java index 96670b0..c72cda2 100644 --- a/src/main/java/goodroad/api/ApiErrors.java +++ b/src/main/java/goodroad/api/ApiErrors.java @@ -1,10 +1,23 @@ package goodroad.api; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import lombok.extern.slf4j.Slf4j; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + import java.time.Instant; @Slf4j @@ -23,8 +36,8 @@ public static class ApiException extends RuntimeException { private final HttpStatus status; private final String code; - public ApiException(HttpStatus status, String code, String msg) { - super(msg); + public ApiException(HttpStatus status, String code, String message) { + super(message); this.status = status; this.code = code; } @@ -42,16 +55,78 @@ public String code() { public static class GlobalHandler { @ExceptionHandler(ApiException.class) - public ResponseEntity handleApiException(ApiException e) { - log.error("API exception: code={}, msg={}", e.code(), e.getMessage(), e); - return ResponseEntity.status(e.status()).body(ApiError.of(e.code(), e.getMessage())); + public ResponseEntity handleApiException(ApiException exception) { + if (exception.status().is5xxServerError()) { + log.error("API exception: code={}, msg={}", exception.code(), exception.getMessage(), exception); + } + return ResponseEntity.status(exception.status()) + .body(ApiError.of(exception.code(), exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException exception) { + return badRequest("REQUEST_VALIDATION_FAILED", "Request fields are invalid"); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadableBody(HttpMessageNotReadableException exception) { + return badRequest("REQUEST_BODY_INVALID", "Request body is missing or contains invalid JSON"); + } + + @ExceptionHandler({ + MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class, + ConstraintViolationException.class, + HandlerMethodValidationException.class + }) + public ResponseEntity handleInvalidParameters(Exception exception) { + return badRequest("REQUEST_VALIDATION_FAILED", "Request parameters are invalid"); + } + + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingPart(MissingServletRequestPartException exception) { + return badRequest("REQUEST_PART_MISSING", "Required request part is missing"); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleUploadTooLarge(MaxUploadSizeExceededException exception) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(ApiError.of("FILE_TOO_LARGE", "Uploaded file is too large")); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleUnsupportedMediaType(HttpMediaTypeNotSupportedException exception) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(ApiError.of("CONTENT_TYPE_UNSUPPORTED", "Content type is not supported")); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleConflict(DataIntegrityViolationException exception) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiError.of("DATA_CONFLICT", "Operation conflicts with current data")); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException exception) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiError.of("ACCESS_DENIED", "Access denied")); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNotFound(NoResourceFoundException exception) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiError.of("ENDPOINT_NOT_FOUND", "Endpoint not found")); } @ExceptionHandler(Exception.class) - public ResponseEntity handleServerError(Exception e) { - log.error("Unexpected exception", e); + public ResponseEntity handleServerError(Exception exception) { + log.error("Unexpected exception", exception); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiError.of("SERVER_INTERNAL_ERROR", "Server internal error")); } + + private ResponseEntity badRequest(String code, String message) { + return ResponseEntity.badRequest().body(ApiError.of(code, message)); + } } -} \ No newline at end of file +} From abb41977954b6351f02baf8be6f9851cbfd6a42b Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Mon, 10 Aug 2026 00:56:26 +0300 Subject: [PATCH 2/4] fix(validation): bound shared text inputs --- src/main/java/goodroad/validation/InputRules.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/goodroad/validation/InputRules.java b/src/main/java/goodroad/validation/InputRules.java index 0f357dd..5745e6c 100644 --- a/src/main/java/goodroad/validation/InputRules.java +++ b/src/main/java/goodroad/validation/InputRules.java @@ -16,7 +16,7 @@ private InputRules() { public static String requireCyrillicText(String value, String code, String fieldName) { String normalized = trimToNull(value); - if (normalized == null || !CYRILLIC_TEXT.matcher(normalized).matches()) { + if (normalized == null || normalized.length() > 80 || !CYRILLIC_TEXT.matcher(normalized).matches()) { throw new ApiException(HttpStatus.BAD_REQUEST, code, fieldName + " is invalid"); } return normalized; From f983f9c69db8c4aacfeb10b5fa662d26197b26e8 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Mon, 10 Aug 2026 00:57:05 +0300 Subject: [PATCH 3/4] fix(validation): validate coordinates and distance calculations --- .../java/goodroad/validation/GeoUtils.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/goodroad/validation/GeoUtils.java diff --git a/src/main/java/goodroad/validation/GeoUtils.java b/src/main/java/goodroad/validation/GeoUtils.java new file mode 100644 index 0000000..310ab78 --- /dev/null +++ b/src/main/java/goodroad/validation/GeoUtils.java @@ -0,0 +1,63 @@ +package goodroad.validation; + +import goodroad.api.ApiErrors.ApiException; +import org.springframework.http.HttpStatus; + +public final class GeoUtils { + private static final double EARTH_RADIUS_KM = 6371.0; + + private GeoUtils() { + } + + public static Coordinates requireCoordinates(Double latitude, Double longitude, String code) { + if (latitude == null || longitude == null + || !Double.isFinite(latitude) || !Double.isFinite(longitude) + || latitude < -90 || latitude > 90 + || longitude < -180 || longitude > 180) { + throw new ApiException(HttpStatus.BAD_REQUEST, code, "Координаты должны содержать допустимые широту и долготу"); + } + return new Coordinates(latitude, longitude); + } + + public static Coordinates parseLatLon(String value, String fieldName) { + if (value == null) { + throw invalidPoint(fieldName); + } + String[] parts = value.split(",", -1); + if (parts.length != 2) { + throw invalidPoint(fieldName); + } + try { + return requireCoordinates( + Double.parseDouble(parts[0].trim()), + Double.parseDouble(parts[1].trim()), + "ROUTE_POINT_INVALID" + ); + } catch (NumberFormatException e) { + throw invalidPoint(fieldName); + } + } + + public static double distanceKm(double firstLat, double firstLon, double secondLat, double secondLon) { + double latitudeDelta = Math.toRadians(secondLat - firstLat); + double longitudeDelta = Math.toRadians(secondLon - firstLon); + double a = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) + + Math.cos(Math.toRadians(firstLat)) * Math.cos(Math.toRadians(secondLat)) + * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2); + return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + private static ApiException invalidPoint(String fieldName) { + return new ApiException( + HttpStatus.BAD_REQUEST, + "ROUTE_POINT_INVALID", + "Поле " + fieldName + " должно иметь формат latitude,longitude" + ); + } + + public record Coordinates(double latitude, double longitude) { + public String asLatLon() { + return latitude + "," + longitude; + } + } +} From f75ae5121532caecdfdd8342a4c1d8624faa82ae Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Mon, 10 Aug 2026 00:57:56 +0300 Subject: [PATCH 4/4] fix(data): add row locks for user state changes --- src/main/java/goodroad/users/repository/UserRepo.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/goodroad/users/repository/UserRepo.java b/src/main/java/goodroad/users/repository/UserRepo.java index 47fe3cb..1277548 100644 --- a/src/main/java/goodroad/users/repository/UserRepo.java +++ b/src/main/java/goodroad/users/repository/UserRepo.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -10,6 +11,14 @@ public interface UserRepo extends JpaRepository { Optional findByPhoneHash(String phoneHash); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select user from UserEntity user where user.phoneHash = :phoneHash") + Optional findByPhoneHashForUpdate(@Param("phoneHash") String phoneHash); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select user from UserEntity user where user.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + List findByRoleIn(List roles); @Modifying @@ -20,4 +29,4 @@ public interface UserRepo extends JpaRepository { and user.lastActiveAt < :cutoff """) int deleteInactiveBefore(@Param("cutoff") Instant cutoff); -} \ No newline at end of file +}