From 045693db21dc8bb31b17979c2daae3de22ef0216 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:33:30 +0800 Subject: [PATCH] Add rate limit for posts and comments --- .../controller/GlobalExceptionHandler.java | 6 +++ .../exception/RateLimitException.java | 10 +++++ .../com/openisle/service/CommentService.java | 11 ++++++ .../com/openisle/service/PostService.java | 6 +++ .../openisle/service/CommentServiceTest.java | 37 +++++++++++++++++++ .../com/openisle/service/PostServiceTest.java | 28 ++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 src/main/java/com/openisle/exception/RateLimitException.java create mode 100644 src/test/java/com/openisle/service/CommentServiceTest.java diff --git a/src/main/java/com/openisle/controller/GlobalExceptionHandler.java b/src/main/java/com/openisle/controller/GlobalExceptionHandler.java index bd7638dba..7af18210b 100644 --- a/src/main/java/com/openisle/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/openisle/controller/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import com.openisle.exception.FieldException; import com.openisle.exception.NotFoundException; +import com.openisle.exception.RateLimitException; import java.util.Map; @@ -22,6 +23,11 @@ public class GlobalExceptionHandler { return ResponseEntity.status(404).body(Map.of("error", ex.getMessage())); } + @ExceptionHandler(RateLimitException.class) + public ResponseEntity handleRateLimitException(RateLimitException ex) { + return ResponseEntity.status(429).body(Map.of("error", ex.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage())); diff --git a/src/main/java/com/openisle/exception/RateLimitException.java b/src/main/java/com/openisle/exception/RateLimitException.java new file mode 100644 index 000000000..bd321980c --- /dev/null +++ b/src/main/java/com/openisle/exception/RateLimitException.java @@ -0,0 +1,10 @@ +package com.openisle.exception; + +/** + * Exception thrown when a user exceeds allowed action rate. + */ +public class RateLimitException extends RuntimeException { + public RateLimitException(String message) { + super(message); + } +} diff --git a/src/main/java/com/openisle/service/CommentService.java b/src/main/java/com/openisle/service/CommentService.java index a48fc996e..a2cca58ff 100644 --- a/src/main/java/com/openisle/service/CommentService.java +++ b/src/main/java/com/openisle/service/CommentService.java @@ -13,6 +13,7 @@ import com.openisle.repository.NotificationRepository; import com.openisle.service.NotificationService; import com.openisle.service.SubscriptionService; import com.openisle.model.Role; +import com.openisle.exception.RateLimitException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,6 +35,11 @@ public class CommentService { private final ImageUploader imageUploader; public Comment addComment(String username, Long postId, String content) { + long recent = commentRepository.countByAuthorAfter(username, + java.time.LocalDateTime.now().minusMinutes(1)); + if (recent >= 3) { + throw new RateLimitException("Too many comments"); + } User author = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Post post = postRepository.findById(postId) @@ -61,6 +67,11 @@ public class CommentService { } public Comment addReply(String username, Long parentId, String content) { + long recent = commentRepository.countByAuthorAfter(username, + java.time.LocalDateTime.now().minusMinutes(1)); + if (recent >= 3) { + throw new RateLimitException("Too many comments"); + } User author = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Comment parent = commentRepository.findById(parentId) diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java index 70d8e5935..e1a05b34e 100644 --- a/src/main/java/com/openisle/service/PostService.java +++ b/src/main/java/com/openisle/service/PostService.java @@ -18,6 +18,7 @@ import com.openisle.repository.ReactionRepository; import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.NotificationRepository; import com.openisle.model.Role; +import com.openisle.exception.RateLimitException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -87,6 +88,11 @@ public class PostService { String title, String content, java.util.List tagIds) { + long recent = postRepository.countByAuthorAfter(username, + java.time.LocalDateTime.now().minusMinutes(5)); + if (recent >= 1) { + throw new RateLimitException("Too many posts"); + } if (tagIds == null || tagIds.isEmpty()) { throw new IllegalArgumentException("At least one tag required"); } diff --git a/src/test/java/com/openisle/service/CommentServiceTest.java b/src/test/java/com/openisle/service/CommentServiceTest.java new file mode 100644 index 000000000..95f55875d --- /dev/null +++ b/src/test/java/com/openisle/service/CommentServiceTest.java @@ -0,0 +1,37 @@ +package com.openisle.service; + +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.UserRepository; +import com.openisle.repository.ReactionRepository; +import com.openisle.repository.CommentSubscriptionRepository; +import com.openisle.repository.NotificationRepository; +import com.openisle.exception.RateLimitException; +import org.junit.jupiter.api.Test; + + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CommentServiceTest { + @Test + void addCommentRespectsRateLimit() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class); + NotificationRepository nRepo = mock(NotificationRepository.class); + ImageUploader imageUploader = mock(ImageUploader.class); + + CommentService service = new CommentService(commentRepo, postRepo, userRepo, + notifService, subService, reactionRepo, subRepo, nRepo, imageUploader); + + when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L); + + assertThrows(RateLimitException.class, + () -> service.addComment("alice", 1L, "hi")); + } +} diff --git a/src/test/java/com/openisle/service/PostServiceTest.java b/src/test/java/com/openisle/service/PostServiceTest.java index 3356d5948..69f437128 100644 --- a/src/test/java/com/openisle/service/PostServiceTest.java +++ b/src/test/java/com/openisle/service/PostServiceTest.java @@ -2,6 +2,7 @@ package com.openisle.service; import com.openisle.model.*; import com.openisle.repository.*; +import com.openisle.exception.RateLimitException; import org.junit.jupiter.api.Test; import java.util.Optional; @@ -50,4 +51,31 @@ class PostServiceTest { verify(postReadService).deleteByPost(post); verify(postRepo).delete(post); } + + @Test + void createPostRespectsRateLimit() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + + PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, + notifService, subService, commentService, commentRepo, + reactionRepo, subRepo, notificationRepo, postReadService, + imageUploader, PublishMode.DIRECT); + + when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); + + assertThrows(RateLimitException.class, + () -> service.createPost("alice", 1L, "t", "c", List.of(1L))); + } }