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 +} diff --git a/src/main/java/goodroad/storage/StorageService.java b/src/main/java/goodroad/storage/StorageService.java index e7b630b..fada9e7 100644 --- a/src/main/java/goodroad/storage/StorageService.java +++ b/src/main/java/goodroad/storage/StorageService.java @@ -1,7 +1,12 @@ package goodroad.storage; +import goodroad.api.ApiErrors.ApiException; +import goodroad.validation.UploadValidationService; +import goodroad.validation.UploadValidationService.UploadPurpose; +import goodroad.validation.UploadValidationService.VerifiedUpload; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; @@ -15,55 +20,58 @@ public class StorageService { private final S3Client s3Client; + private final UploadValidationService uploadValidator; @Value("${yandex.storage.bucket}") private String bucket; public String uploadAvatar(MultipartFile file, String userId) { - try { + VerifiedUpload verified = uploadValidator.validate(file, UploadPurpose.AVATAR); - String ext = getExt(file.getOriginalFilename()); + try { - String key = "avatars/" + userId + "/" + UUID.randomUUID() + ext; + String key = "avatars/" + userId + "/" + UUID.randomUUID() + verified.extension(); s3Client.putObject( PutObjectRequest.builder() .bucket(bucket) .key(key) - .contentType(file.getContentType()) + .contentType(verified.contentType()) + .contentLength((long) verified.bytes().length) .build(), - RequestBody.fromBytes(file.getBytes()) + RequestBody.fromBytes(verified.bytes()) ); return "https://storage.yandexcloud.net/" + bucket + "/" + key; - } catch (Exception e) { - throw new RuntimeException("Upload failed", e); + } catch (RuntimeException e) { + throw new ApiException(HttpStatus.BAD_GATEWAY, "STORAGE_UNAVAILABLE", "File storage is unavailable"); } } public String uploadReviewPhoto(MultipartFile file, String userId) { - try { + VerifiedUpload verified = uploadValidator.validate(file, UploadPurpose.REVIEW_PHOTO); - String ext = getExt(file.getOriginalFilename()); + try { String key = "reviews/" + userId + "/" + UUID.randomUUID() - + ext; + + verified.extension(); s3Client.putObject( PutObjectRequest.builder() .bucket(bucket) .key(key) - .contentType(file.getContentType()) + .contentType(verified.contentType()) + .contentLength((long) verified.bytes().length) .build(), - RequestBody.fromBytes(file.getBytes()) + RequestBody.fromBytes(verified.bytes()) ); return "https://storage.yandexcloud.net/" @@ -71,30 +79,31 @@ public String uploadReviewPhoto(MultipartFile file, String userId) { + "/" + key; - } catch (Exception e) { - throw new RuntimeException("Upload failed", e); + } catch (RuntimeException e) { + throw new ApiException(HttpStatus.BAD_GATEWAY, "STORAGE_UNAVAILABLE", "File storage is unavailable"); } } public String uploadVolunteerCertificate(MultipartFile file, String userId) { - try { + VerifiedUpload verified = uploadValidator.validate(file, UploadPurpose.VOLUNTEER_CERTIFICATE); - String ext = getExt(file.getOriginalFilename()); + try { String key = "volunteer-certificates/" + userId + "/" + UUID.randomUUID() - + ext; + + verified.extension(); s3Client.putObject( PutObjectRequest.builder() .bucket(bucket) .key(key) - .contentType(file.getContentType()) + .contentType(verified.contentType()) + .contentLength((long) verified.bytes().length) .build(), - RequestBody.fromBytes(file.getBytes()) + RequestBody.fromBytes(verified.bytes()) ); return "https://storage.yandexcloud.net/" @@ -102,14 +111,8 @@ public String uploadVolunteerCertificate(MultipartFile file, String userId) { + "/" + key; - } catch (Exception e) { - throw new RuntimeException("Upload failed", e); + } catch (RuntimeException e) { + throw new ApiException(HttpStatus.BAD_GATEWAY, "STORAGE_UNAVAILABLE", "File storage is unavailable"); } } - - private String getExt(String name) { - if (name == null) return ""; - int i = name.lastIndexOf("."); - return i == -1 ? "" : name.substring(i); - } } \ No newline at end of file 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 +} 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; + } + } +} 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; diff --git a/src/main/java/goodroad/validation/TrustedUrlService.java b/src/main/java/goodroad/validation/TrustedUrlService.java new file mode 100644 index 0000000..2ed6d52 --- /dev/null +++ b/src/main/java/goodroad/validation/TrustedUrlService.java @@ -0,0 +1,71 @@ +package goodroad.validation; + +import goodroad.api.ApiErrors.ApiException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Set; + +@Service +public class TrustedUrlService { + private static final String STORAGE_HOST = "storage.yandexcloud.net"; + private static final Set DOBRO_HOSTS = Set.of("dobro.ru", "www.dobro.ru"); + + private final String storageBucket; + + public TrustedUrlService(@Value("${yandex.storage.bucket}") String storageBucket) { + this.storageBucket = storageBucket; + } + + public String requireDobroProfileUrl(String rawUrl) { + URI uri = parseHttpsUrl(rawUrl, "DOBRO_URL_INVALID", "Укажите корректную HTTPS-ссылку на профиль dobro.ru"); + String host = uri.getHost().toLowerCase(Locale.ROOT); + if (!DOBRO_HOSTS.contains(host) || uri.getPort() != -1 || uri.getUserInfo() != null) { + throw invalid("DOBRO_URL_INVALID", "Ссылка должна вести на домен dobro.ru"); + } + return uri.normalize().toString(); + } + + public String requireOwnedStorageUrl(String rawUrl, String directory, Long userId, String code) { + URI uri = parseHttpsUrl(rawUrl, code, "Ссылка на файл имеет неверный формат"); + String expectedPrefix = "/" + storageBucket + "/" + directory + "/" + userId + "/"; + String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath().toLowerCase(Locale.ROOT); + boolean containsEncodedSeparator = rawPath.contains("%2f") || rawPath.contains("%5c") || rawPath.contains("%2e"); + if (!STORAGE_HOST.equalsIgnoreCase(uri.getHost()) + || uri.getPort() != -1 + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || containsEncodedSeparator + || uri.normalize().getPath() == null + || !uri.normalize().getPath().startsWith(expectedPrefix) + || uri.normalize().getPath().equals(expectedPrefix)) { + throw invalid(code, "Разрешены только файлы, загруженные текущим пользователем через GoodRoad"); + } + return uri.normalize().toString(); + } + + private URI parseHttpsUrl(String rawUrl, String code, String message) { + String value = InputRules.trimToNull(rawUrl); + if (value == null || value.length() > 512) { + throw invalid(code, message); + } + try { + URI uri = new URI(value); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null) { + throw invalid(code, message); + } + return uri; + } catch (URISyntaxException e) { + throw invalid(code, message); + } + } + + private ApiException invalid(String code, String message) { + return new ApiException(HttpStatus.BAD_REQUEST, code, message); + } +} diff --git a/src/main/java/goodroad/validation/UploadValidationService.java b/src/main/java/goodroad/validation/UploadValidationService.java new file mode 100644 index 0000000..634e4d6 --- /dev/null +++ b/src/main/java/goodroad/validation/UploadValidationService.java @@ -0,0 +1,106 @@ +package goodroad.validation; + +import goodroad.api.ApiErrors.ApiException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +@Service +public class UploadValidationService { + private static final long MAX_FILE_SIZE = 10L * 1024 * 1024; + + public VerifiedUpload validate(MultipartFile file, UploadPurpose purpose) { + if (file == null || file.isEmpty()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_EMPTY", "Выберите непустой файл для загрузки"); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new ApiException(HttpStatus.PAYLOAD_TOO_LARGE, "FILE_TOO_LARGE", "Размер файла не должен превышать 10 МБ"); + } + + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException e) { + throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "FILE_READ_FAILED", "Не удалось прочитать загруженный файл"); + } + + DetectedType detectedType = detectType(bytes); + if (!purpose.allowedTypes.contains(detectedType)) { + throw new ApiException( + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + "FILE_CONTENT_TYPE_INVALID", + purpose == UploadPurpose.VOLUNTEER_CERTIFICATE + ? "Сертификат должен быть файлом JPEG или PNG" + : "Изображение должно быть файлом JPEG, PNG или WEBP" + ); + } + + return new VerifiedUpload(bytes, detectedType.contentType, detectedType.extension); + } + + private DetectedType detectType(byte[] bytes) { + if (startsWith(bytes, new int[] {0xFF, 0xD8, 0xFF})) { + return DetectedType.JPEG; + } + if (startsWith(bytes, new int[] {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})) { + return DetectedType.PNG; + } + if (bytes.length >= 12 + && ascii(bytes, 0, 4).equals("RIFF") + && ascii(bytes, 8, 4).equals("WEBP")) { + return DetectedType.WEBP; + } + return DetectedType.UNKNOWN; + } + + private boolean startsWith(byte[] bytes, int[] signature) { + if (bytes.length < signature.length) { + return false; + } + for (int index = 0; index < signature.length; index++) { + if ((bytes[index] & 0xFF) != signature[index]) { + return false; + } + } + return true; + } + + private String ascii(byte[] bytes, int offset, int length) { + return new String(bytes, offset, length, StandardCharsets.US_ASCII); + } + + public enum UploadPurpose { + AVATAR(Set.of(DetectedType.JPEG, DetectedType.PNG, DetectedType.WEBP)), + REVIEW_PHOTO(Set.of(DetectedType.JPEG, DetectedType.PNG, DetectedType.WEBP)), + VOLUNTEER_CERTIFICATE(Set.of(DetectedType.JPEG, DetectedType.PNG)); + + private final Set allowedTypes; + + UploadPurpose(Set allowedTypes) { + this.allowedTypes = allowedTypes; + } + } + + private enum DetectedType { + JPEG("image/jpeg", ".jpg"), + PNG("image/png", ".png"), + WEBP("image/webp", ".webp"), + UNKNOWN("application/octet-stream", ""); + + private final String contentType; + private final String extension; + + DetectedType(String contentType, String extension) { + this.contentType = contentType; + this.extension = extension; + } + } + + public record VerifiedUpload(byte[] bytes, String contentType, String extension) { + } +} diff --git a/src/main/java/goodroad/volunteer/VolunteerService.java b/src/main/java/goodroad/volunteer/VolunteerService.java index cd9f13a..2c173f7 100644 --- a/src/main/java/goodroad/volunteer/VolunteerService.java +++ b/src/main/java/goodroad/volunteer/VolunteerService.java @@ -8,7 +8,9 @@ import goodroad.storage.StorageService; import goodroad.users.repository.UserEntity; import goodroad.users.repository.UserRepo; +import goodroad.validation.GeoUtils; import goodroad.validation.InputRules; +import goodroad.validation.TrustedUrlService; import goodroad.volunteer.repository.*; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -33,6 +35,7 @@ public class VolunteerService { private final VolunteerApplicationPhotoRepo applicationPhotos; private final HelpRequestRepo requests; private final StorageService storageService; + private final TrustedUrlService trustedUrls; @Autowired(required = false) private PointLedgerService pointLedger; @@ -45,13 +48,15 @@ public VolunteerService( VolunteerApplicationRepo applications, VolunteerApplicationPhotoRepo applicationPhotos, HelpRequestRepo requests, - StorageService storageService + StorageService storageService, + TrustedUrlService trustedUrls ) { this.users = users; this.applications = applications; this.applicationPhotos = applicationPhotos; this.requests = requests; this.storageService = storageService; + this.trustedUrls = trustedUrls; } public record VolunteerMenuResp(boolean volunteer, String applicationStatus, String rejectReason) {} @@ -103,17 +108,29 @@ public VolunteerApplicationResp createApplication(String phoneFromAuth, CreateVo VolunteerApplicationEntity app = new VolunteerApplicationEntity(); app.setApplicant(user); - app.setDobroUrl(requireUrl(req.dobroUrl(), "DOBRO_URL_INVALID", "Dobro.ru link is invalid")); + app.setDobroUrl(trustedUrls.requireDobroProfileUrl(req.dobroUrl())); app.setPhone(Crypto.normPhone(req.phone())); if (app.getPhone().isEmpty()) { throw bad("PHONE_INVALID", "Phone is invalid"); } - app.setSocialNickname(InputRules.trimToNull(req.socialNickname())); + String socialNickname = InputRules.trimToNull(req.socialNickname()); + if (socialNickname != null && socialNickname.length() > 120) { + throw bad("SOCIAL_NICKNAME_TOO_LONG", "Social nickname is too long"); + } + app.setSocialNickname(socialNickname); applications.save(app); if (req.certificatePhotoUrls() != null) { + if (req.certificatePhotoUrls().size() > 10) { + throw bad("CERTIFICATE_PHOTO_LIMIT_EXCEEDED", "Too many certificate photos"); + } for (String rawUrl : req.certificatePhotoUrls()) { - String url = requireUrl(rawUrl, "CERTIFICATE_URL_INVALID", "Certificate URL is invalid"); + String url = trustedUrls.requireOwnedStorageUrl( + rawUrl, + "volunteer-certificates", + user.getId(), + "CERTIFICATE_URL_INVALID" + ); VolunteerApplicationPhotoEntity photo = new VolunteerApplicationPhotoEntity(); photo.setApplication(app); photo.setUrl(url); @@ -200,8 +217,18 @@ public List listMyWards(String phoneFromAuth) { @Transactional(readOnly = true) public List listAvailableRequests(String phoneFromAuth, Double latitude, Double longitude) { UserEntity volunteer = requireVolunteer(phoneFromAuth); + if ((latitude == null) != (longitude == null)) { + throw bad("LOCATION_INCOMPLETE", "Latitude and longitude must be provided together"); + } + if (latitude != null) { + GeoUtils.requireCoordinates(latitude, longitude, "LOCATION_INVALID"); + } return requests.findByStatusOrderByDateAscTimeAscCreatedAtAsc("OPEN").stream() .filter(request -> !request.getRequester().getId().equals(volunteer.getId())) + .filter(request -> !isPast(request)) + .filter(request -> latitude == null + || request.getStartLatitude() != null && request.getStartLongitude() != null + && GeoUtils.distanceKm(latitude, longitude, request.getStartLatitude(), request.getStartLongitude()) <= 5.0) .sorted(Comparator.comparing(HelpRequestEntity::getDate).thenComparing(HelpRequestEntity::getTime)) .map(request -> toHelpResp(request, volunteer, false)) .toList(); @@ -216,7 +243,7 @@ public HelpRequestResp getHelpRequest(String phoneFromAuth, String id) { @Transactional public HelpRequestResp cancelOwnRequest(String phoneFromAuth, String id) { UserEntity user = findCurrent(phoneFromAuth); - HelpRequestEntity request = findRequest(id); + HelpRequestEntity request = findRequestForUpdate(id); requireRequester(request, user); if ("COMPLETED".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_COMPLETED", "Completed request cannot be cancelled"); @@ -229,7 +256,7 @@ public HelpRequestResp cancelOwnRequest(String phoneFromAuth, String id) { @Transactional public void deleteOwnRequest(String phoneFromAuth, String id) { UserEntity user = findCurrent(phoneFromAuth); - HelpRequestEntity request = findRequest(id); + HelpRequestEntity request = findRequestForUpdate(id); requireRequester(request, user); if ("COMPLETED".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_COMPLETED", "Completed request cannot be deleted"); @@ -246,10 +273,13 @@ public void deleteOwnRequest(String phoneFromAuth, String id) { @Transactional public HelpRequestResp acceptRequest(String phoneFromAuth, String id) { UserEntity volunteer = requireVolunteer(phoneFromAuth); - HelpRequestEntity request = findRequest(id); + HelpRequestEntity request = findRequestForUpdate(id); if (!"OPEN".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_OPEN", "Help request is not open"); } + if (isPast(request)) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_START_IN_PAST", "Help request start time is in the past"); + } if (request.getRequester().getId().equals(volunteer.getId())) { throw new ApiException(HttpStatus.BAD_REQUEST, "OWN_REQUEST_ACCEPT", "Volunteer cannot accept own request"); } @@ -262,7 +292,7 @@ public HelpRequestResp acceptRequest(String phoneFromAuth, String id) { @Transactional public HelpRequestResp withdrawResponse(String phoneFromAuth, String id) { UserEntity volunteer = requireVolunteer(phoneFromAuth); - HelpRequestEntity request = findRequest(id); + HelpRequestEntity request = findRequestForUpdate(id); requireVolunteerOfRequest(request, volunteer); if (!"ACCEPTED".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_CANNOT_WITHDRAW", "Response cannot be withdrawn"); @@ -276,7 +306,7 @@ public HelpRequestResp withdrawResponse(String phoneFromAuth, String id) { @Transactional public HelpRequestResp setWalkRoute(String phoneFromAuth, String id, WalkRouteReq req) { UserEntity user = findCurrent(phoneFromAuth); - HelpRequestEntity request = findRequest(id); + HelpRequestEntity request = findRequestForUpdate(id); requireParticipant(request, user); if (!"ACCEPTED".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_ACCEPTED", "Route can be saved only for accepted request"); @@ -288,7 +318,7 @@ public HelpRequestResp setWalkRoute(String phoneFromAuth, String id, WalkRouteRe @Transactional public HelpRequestResp finishWalk(String phoneFromAuth, String id) { UserEntity user = findCurrent(phoneFromAuth); - HelpRequestEntity request = findRequest(id); + HelpRequestEntity request = findRequestForUpdate(id); requireParticipant(request, user); if (!"ACCEPTED".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_ACCEPTED", "Walk can be finished only for accepted request"); @@ -306,7 +336,10 @@ public HelpRequestResp finishWalk(String phoneFromAuth, String id) { if (pointLedger != null) { pointLedger.earn(volunteer, WALK_REWARD, "VOLUNTEER_WALK_COMPLETED", "Завершена волонтерская прогулка", null, "HELP_REQUEST", request.getId()); } else { - volunteer.setTotalPoints(volunteer.getTotalPoints() + WALK_REWARD); + int currentPoints = volunteer.getTotalPoints() == null ? 0 : Math.max(0, volunteer.getTotalPoints()); + volunteer.setTotalPoints(currentPoints > Integer.MAX_VALUE - WALK_REWARD + ? Integer.MAX_VALUE + : currentPoints + WALK_REWARD); users.save(volunteer); } if (taskService != null) { @@ -371,6 +404,12 @@ private void fillRequest(HelpRequestEntity request, HelpRequestReq req) { if (phone.isEmpty()) { throw bad("PHONE_INVALID", "Phone is invalid"); } + if (fromAddress.length() > 500 || toAddress.length() > 500) { + throw bad("ADDRESS_TOO_LONG", "Address is too long"); + } + if (comment.length() > 2000) { + throw bad("COMMENT_TOO_LONG", "Comment is too long"); + } request.setFromAddress(fromAddress); request.setToAddress(toAddress); if (req.startLatitude() != null || req.startLongitude() != null) { @@ -378,10 +417,19 @@ private void fillRequest(HelpRequestEntity request, HelpRequestReq req) { } request.setStartLatitude(req.startLatitude()); request.setStartLongitude(req.startLongitude()); - request.setDate(parseDate(req.date())); - request.setTime(parseTime(req.time())); + LocalDate date = parseDate(req.date()); + LocalTime time = parseTime(req.time()); + if (LocalDateTime.of(date, time).isBefore(LocalDateTime.now())) { + throw bad("REQUEST_START_IN_PAST", "Help request start time must be in the future"); + } + request.setDate(date); + request.setTime(time); request.setPhone(phone); - request.setSocialNickname(InputRules.trimToNull(req.socialNickname())); + String socialNickname = InputRules.trimToNull(req.socialNickname()); + if (socialNickname != null && socialNickname.length() > 120) { + throw bad("SOCIAL_NICKNAME_TOO_LONG", "Social nickname is too long"); + } + request.setSocialNickname(socialNickname); request.setComment(comment); } @@ -446,10 +494,7 @@ private int[] decodePolylineValue(String encoded, int index) { } private void validateCoordinates(Double latitude, Double longitude) { - if (latitude == null || longitude == null - || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { - throw bad("LOCATION_INVALID", "Location is invalid"); - } + GeoUtils.requireCoordinates(latitude, longitude, "LOCATION_INVALID"); } private UserEntity findCurrent(String phoneFromAuth) { @@ -458,6 +503,7 @@ private UserEntity findCurrent(String phoneFromAuth) { throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_PHONE_NOT_FOUND", "User with given phone not found"); } return users.findByPhoneHash(Crypto.sha256Hex(phoneNorm)) + .filter(UserEntity::isActive) .orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_PHONE_NOT_FOUND", "User with given phone not found")); } @@ -514,9 +560,16 @@ private HelpRequestEntity findRequest(String id) { .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "HELP_REQUEST_NOT_FOUND", "Help request not found")); } + private HelpRequestEntity findRequestForUpdate(String id) { + return requests.findByIdForUpdate(parseId(id, "HELP_REQUEST_ID_INVALID", "Help request id is invalid")) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "HELP_REQUEST_NOT_FOUND", "Help request not found")); + } + private Long parseId(String raw, String code, String msg) { try { - return Long.parseLong(raw); + long value = Long.parseLong(raw); + if (value <= 0) throw new NumberFormatException(); + return value; } catch (Exception e) { throw new ApiException(HttpStatus.BAD_REQUEST, code, msg); } @@ -538,12 +591,8 @@ private LocalTime parseTime(String raw) { } } - private String requireUrl(String raw, String code, String msg) { - String value = InputRules.trimToNull(raw); - if (value == null || !(value.startsWith("http://") || value.startsWith("https://"))) { - throw new ApiException(HttpStatus.BAD_REQUEST, code, msg); - } - return value; + private boolean isPast(HelpRequestEntity request) { + return LocalDateTime.of(request.getDate(), request.getTime()).isBefore(LocalDateTime.now()); } private ApiException bad(String code, String msg) { diff --git a/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java b/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java index 50a5331..80081bf 100644 --- a/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java @@ -1,10 +1,18 @@ package goodroad.volunteer.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; import java.time.*; import java.util.List; public interface HelpRequestRepo extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select request from HelpRequestEntity request where request.id = :id") + java.util.Optional findByIdForUpdate(@Param("id") Long id); + List findByRequesterIdOrderByDateDescTimeDescCreatedAtDesc(Long requesterId); List findByVolunteerIdOrderByDateDescTimeDescCreatedAtDesc(Long volunteerId); List findByStatusOrderByDateAscTimeAscCreatedAtAsc(String status); diff --git a/src/test/java/goodroad/validation/TrustedUrlServiceTest.java b/src/test/java/goodroad/validation/TrustedUrlServiceTest.java new file mode 100644 index 0000000..9c71af6 --- /dev/null +++ b/src/test/java/goodroad/validation/TrustedUrlServiceTest.java @@ -0,0 +1,50 @@ +package goodroad.validation; + +import goodroad.api.ApiErrors.ApiException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TrustedUrlServiceTest { + private final TrustedUrlService service = new TrustedUrlService("goodroad-bucket"); + + @Test + void acceptsDobroHttpsProfile() { + assertEquals( + "https://dobro.ru/volunteer/123", + service.requireDobroProfileUrl("https://dobro.ru/volunteer/123") + ); + } + + @Test + void rejectsLookalikeDobroDomain() { + assertThrows(ApiException.class, () -> service.requireDobroProfileUrl( + "https://dobro.ru.evil.example/payload.exe" + )); + assertThrows(ApiException.class, () -> service.requireDobroProfileUrl( + "https://attacker@dobro.ru/volunteer/123" + )); + } + + @Test + void acceptsOnlyCurrentUsersUploadedCertificate() { + String expected = "https://storage.yandexcloud.net/goodroad-bucket/volunteer-certificates/10/cert.jpg"; + assertEquals(expected, service.requireOwnedStorageUrl( + expected, "volunteer-certificates", 10L, "CERTIFICATE_URL_INVALID" + )); + + assertThrows(ApiException.class, () -> service.requireOwnedStorageUrl( + "https://storage.yandexcloud.net/goodroad-bucket/volunteer-certificates/11/malware.jpg", + "volunteer-certificates", + 10L, + "CERTIFICATE_URL_INVALID" + )); + assertThrows(ApiException.class, () -> service.requireOwnedStorageUrl( + "https://storage.yandexcloud.net/goodroad-bucket/volunteer-certificates/10/%2e%2e/reviews/file.jpg", + "volunteer-certificates", + 10L, + "CERTIFICATE_URL_INVALID" + )); + } +} diff --git a/src/test/java/goodroad/validation/UploadValidationServiceTest.java b/src/test/java/goodroad/validation/UploadValidationServiceTest.java new file mode 100644 index 0000000..514784f --- /dev/null +++ b/src/test/java/goodroad/validation/UploadValidationServiceTest.java @@ -0,0 +1,51 @@ +package goodroad.validation; + +import goodroad.api.ApiErrors.ApiException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UploadValidationServiceTest { + private final UploadValidationService service = new UploadValidationService(); + + @Test + void acceptsPngBySignatureEvenWhenClientContentTypeIsWrong() { + byte[] png = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2}; + MockMultipartFile file = new MockMultipartFile("file", "image.bin", "application/octet-stream", png); + + UploadValidationService.VerifiedUpload result = service.validate( + file, + UploadValidationService.UploadPurpose.REVIEW_PHOTO + ); + + assertEquals("image/png", result.contentType()); + assertEquals(".png", result.extension()); + } + + @Test + void rejectsExecutableRenamedToJpeg() { + byte[] executable = new byte[] {'M', 'Z', 1, 2, 3, 4}; + MockMultipartFile file = new MockMultipartFile("file", "certificate.jpg", "image/jpeg", executable); + + ApiException exception = assertThrows(ApiException.class, () -> service.validate( + file, + UploadValidationService.UploadPurpose.VOLUNTEER_CERTIFICATE + )); + + assertEquals("FILE_CONTENT_TYPE_INVALID", exception.code()); + } + + @Test + void rejectsPdfAsVolunteerCertificateToAvoidActiveDocumentContent() { + MockMultipartFile file = new MockMultipartFile( + "file", "certificate.pdf", "application/pdf", "%PDF-1.7".getBytes() + ); + + assertThrows(ApiException.class, () -> service.validate( + file, + UploadValidationService.UploadPurpose.VOLUNTEER_CERTIFICATE + )); + } +} diff --git a/src/test/java/goodroad/volunteer/VolunteerServiceTest.java b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java index 8f32d72..210fd58 100644 --- a/src/test/java/goodroad/volunteer/VolunteerServiceTest.java +++ b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java @@ -5,7 +5,9 @@ import goodroad.storage.StorageService; import goodroad.users.repository.UserEntity; import goodroad.users.repository.UserRepo; +import goodroad.validation.TrustedUrlService; import goodroad.volunteer.repository.*; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -42,9 +44,20 @@ class VolunteerServiceTest { @Mock private StorageService storageService; + @Mock + private TrustedUrlService trustedUrls; + @InjectMocks private VolunteerService service; + @BeforeEach + void configureTrustedUrls() { + lenient().when(trustedUrls.requireDobroProfileUrl(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(trustedUrls.requireOwnedStorageUrl(anyString(), anyString(), anyLong(), anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + @Test void shouldCreateVolunteerApplicationWithCertificateLinks() { UserEntity user = user(1L, "USER", "+79990000001"); @@ -152,7 +165,7 @@ void shouldAcceptRequestAndShowContactsToVolunteer() { UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); HelpRequestEntity request = helpRequest(30L, requester, null, "OPEN"); when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); when(requests.save(request)).thenReturn(request); VolunteerService.HelpRequestResp result = service.acceptRequest("+79990000002", "30"); @@ -171,7 +184,7 @@ void shouldWithdrawResponseWithoutPenalty() { volunteer.setTotalPoints(80); HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); when(requests.save(request)).thenReturn(request); VolunteerService.HelpRequestResp result = service.withdrawResponse("+79990000002", "30"); @@ -188,7 +201,7 @@ void shouldSaveWalkRouteForAcceptedRequest() { UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(requester)); - when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); when(requests.save(request)).thenReturn(request); VolunteerService.HelpRequestResp result = service.setWalkRoute("+79990000001", "30", routeReq()); @@ -204,7 +217,7 @@ void shouldCompleteWalkAndAddVolunteerPointsAfterBothParticipantsFinish() { HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(requester)); when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.findByIdForUpdate(30L)).thenReturn(Optional.of(request)); when(requests.save(request)).thenReturn(request); VolunteerService.HelpRequestResp afterRequester = service.finishWalk("+79990000001", "30");