From d69f7251e099950d06864c9a139f3e082bd9b4c1 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:00:47 +0800 Subject: [PATCH 1/2] Add image upload validations and random naming --- .../com/openisle/controller/UserController.java | 13 +++++++++++++ .../java/com/openisle/service/CosImageUploader.java | 12 ++++++++++-- src/main/resources/application.properties | 4 ++++ .../com/openisle/controller/UserControllerTest.java | 12 ++++++++++++ src/test/resources/application.properties | 4 ++++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/openisle/controller/UserController.java b/src/main/java/com/openisle/controller/UserController.java index 96ae30ebd..dac951982 100644 --- a/src/main/java/com/openisle/controller/UserController.java +++ b/src/main/java/com/openisle/controller/UserController.java @@ -3,6 +3,7 @@ package com.openisle.controller; import com.openisle.model.User; import com.openisle.service.ImageUploader; import com.openisle.service.UserService; +import org.springframework.beans.factory.annotation.Value; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -20,6 +21,12 @@ public class UserController { private final UserService userService; private final ImageUploader imageUploader; + @Value("${app.upload.check-type:true}") + private boolean checkImageType; + + @Value("${app.upload.max-size:5242880}") + private long maxUploadSize; + @GetMapping("/me") public ResponseEntity me(Authentication auth) { User user = userService.findByUsername(auth.getName()).orElseThrow(); @@ -29,6 +36,12 @@ public class UserController { @PostMapping("/me/avatar") public ResponseEntity uploadAvatar(@RequestParam("file") MultipartFile file, Authentication auth) { + if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) { + return ResponseEntity.badRequest().body(Map.of("error", "File is not an image")); + } + if (file.getSize() > maxUploadSize) { + return ResponseEntity.badRequest().body(Map.of("error", "File too large")); + } String url = null; try { url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); diff --git a/src/main/java/com/openisle/service/CosImageUploader.java b/src/main/java/com/openisle/service/CosImageUploader.java index 2142fd30d..9e5d28375 100644 --- a/src/main/java/com/openisle/service/CosImageUploader.java +++ b/src/main/java/com/openisle/service/CosImageUploader.java @@ -12,6 +12,7 @@ import org.springframework.scheduling.concurrent.CustomizableThreadFactory; import org.springframework.stereotype.Service; import java.io.ByteArrayInputStream; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -52,15 +53,22 @@ public class CosImageUploader extends ImageUploader { @Override public CompletableFuture upload(byte[] data, String filename) { return CompletableFuture.supplyAsync(() -> { + String ext = ""; + int dot = filename.lastIndexOf('.'); + if (dot != -1) { + ext = filename.substring(dot); + } + String randomName = UUID.randomUUID().toString().replace("-", "") + ext; + ObjectMetadata meta = new ObjectMetadata(); meta.setContentLength(data.length); PutObjectRequest req = new PutObjectRequest( bucketName, - filename, + randomName, new ByteArrayInputStream(data), meta); cosClient.putObject(req); - return baseUrl + "/" + filename; + return baseUrl + "/" + randomName; }, executor); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b59558ad2..6cb56ee1b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,5 +10,9 @@ cos.secret-key=${COS_SECRET_KEY:} cos.region=${COS_REGION:ap-guangzhou} cos.bucket-name=${COS_BUCKET_NAME:} +# Image upload configuration +app.upload.check-type=${UPLOAD_CHECK_TYPE:true} +app.upload.max-size=${UPLOAD_MAX_SIZE:5242880} + app.jwt.secret=${JWT_SECRET:ChangeThisSecretKeyForJwt} app.jwt.expiration=${JWT_EXPIRATION:86400000} diff --git a/src/test/java/com/openisle/controller/UserControllerTest.java b/src/test/java/com/openisle/controller/UserControllerTest.java index 4e789776a..eec00f78c 100644 --- a/src/test/java/com/openisle/controller/UserControllerTest.java +++ b/src/test/java/com/openisle/controller/UserControllerTest.java @@ -57,4 +57,16 @@ class UserControllerTest { Mockito.verify(userService).updateAvatar("alice", "http://img/a.png"); } + + @Test + void uploadAvatarRejectsNonImage() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "a.txt", MediaType.TEXT_PLAIN_VALUE, "text".getBytes()); + + mockMvc.perform(multipart("/api/users/me/avatar").file(file).principal(new UsernamePasswordAuthenticationToken("alice","p"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("File is not an image")); + + Mockito.verify(imageUploader, Mockito.never()).upload(any(), any()); + } + } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 0ff8e0dfa..489fa5f31 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -11,5 +11,9 @@ cos.secret-key=dummy cos.region=ap-guangzhou cos.bucket-name=testbucket +# Image upload configuration for tests +app.upload.check-type=true +app.upload.max-size=1048576 + app.jwt.secret=TestSecret app.jwt.expiration=3600000 From af1f3a923071f095178420a12f5ff4c34e62324c Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:09:47 +0800 Subject: [PATCH 2/2] feat: add post publish mode with review --- .../controller/AdminPostController.java | 56 ++++++++++++ src/main/java/com/openisle/model/Post.java | 8 ++ .../java/com/openisle/model/PostStatus.java | 9 ++ .../java/com/openisle/model/PublishMode.java | 9 ++ .../openisle/repository/PostRepository.java | 4 + .../com/openisle/service/PostService.java | 34 +++++++- src/main/resources/application.properties | 3 + .../PublishModeIntegrationTest.java | 86 +++++++++++++++++++ src/test/resources/application.properties | 3 + 9 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/openisle/controller/AdminPostController.java create mode 100644 src/main/java/com/openisle/model/PostStatus.java create mode 100644 src/main/java/com/openisle/model/PublishMode.java create mode 100644 src/test/java/com/openisle/integration/PublishModeIntegrationTest.java diff --git a/src/main/java/com/openisle/controller/AdminPostController.java b/src/main/java/com/openisle/controller/AdminPostController.java new file mode 100644 index 000000000..57e36cc50 --- /dev/null +++ b/src/main/java/com/openisle/controller/AdminPostController.java @@ -0,0 +1,56 @@ +package com.openisle.controller; + +import com.openisle.model.Post; +import com.openisle.service.PostService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Endpoints for administrators to manage posts. + */ +@RestController +@RequestMapping("/api/admin/posts") +@RequiredArgsConstructor +public class AdminPostController { + private final PostService postService; + + @GetMapping("/pending") + public List pendingPosts() { + return postService.listPendingPosts().stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @PostMapping("/{id}/approve") + public PostDto approve(@PathVariable Long id) { + return toDto(postService.approvePost(id)); + } + + private PostDto toDto(Post post) { + PostDto dto = new PostDto(); + dto.setId(post.getId()); + dto.setTitle(post.getTitle()); + dto.setContent(post.getContent()); + dto.setCreatedAt(post.getCreatedAt()); + dto.setAuthor(post.getAuthor().getUsername()); + dto.setCategory(post.getCategory().getName()); + dto.setViews(post.getViews()); + return dto; + } + + @Data + private static class PostDto { + private Long id; + private String title; + private String content; + private LocalDateTime createdAt; + private String author; + private String category; + private long views; + } +} diff --git a/src/main/java/com/openisle/model/Post.java b/src/main/java/com/openisle/model/Post.java index adbce523d..a7d823752 100644 --- a/src/main/java/com/openisle/model/Post.java +++ b/src/main/java/com/openisle/model/Post.java @@ -5,8 +5,12 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; + import java.time.LocalDateTime; +/** + * Post entity representing an article posted by a user. + */ @Entity @Getter @Setter @@ -37,6 +41,10 @@ public class Post { @Column(nullable = false) private long views = 0; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostStatus status = PostStatus.PUBLISHED; + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); diff --git a/src/main/java/com/openisle/model/PostStatus.java b/src/main/java/com/openisle/model/PostStatus.java new file mode 100644 index 000000000..e0d9f8ec1 --- /dev/null +++ b/src/main/java/com/openisle/model/PostStatus.java @@ -0,0 +1,9 @@ +package com.openisle.model; + +/** + * Status of a post during its lifecycle. + */ +public enum PostStatus { + PUBLISHED, + PENDING +} diff --git a/src/main/java/com/openisle/model/PublishMode.java b/src/main/java/com/openisle/model/PublishMode.java new file mode 100644 index 000000000..2b912cd78 --- /dev/null +++ b/src/main/java/com/openisle/model/PublishMode.java @@ -0,0 +1,9 @@ +package com.openisle.model; + +/** + * Application-wide article publish mode. + */ +public enum PublishMode { + DIRECT, + REVIEW +} diff --git a/src/main/java/com/openisle/repository/PostRepository.java b/src/main/java/com/openisle/repository/PostRepository.java index c720ec1b0..63b3a9ab5 100644 --- a/src/main/java/com/openisle/repository/PostRepository.java +++ b/src/main/java/com/openisle/repository/PostRepository.java @@ -1,7 +1,11 @@ package com.openisle.repository; import com.openisle.model.Post; +import com.openisle.model.PostStatus; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface PostRepository extends JpaRepository { + List findByStatus(PostStatus status); } diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java index 32997e772..d4fe356b3 100644 --- a/src/main/java/com/openisle/service/PostService.java +++ b/src/main/java/com/openisle/service/PostService.java @@ -1,22 +1,35 @@ package com.openisle.service; import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.model.PublishMode; import com.openisle.model.User; import com.openisle.model.Category; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.List; @Service -@RequiredArgsConstructor public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final CategoryRepository categoryRepository; + private final PublishMode publishMode; + + @org.springframework.beans.factory.annotation.Autowired + public PostService(PostRepository postRepository, + UserRepository userRepository, + CategoryRepository categoryRepository, + @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { + this.postRepository = postRepository; + this.userRepository = userRepository; + this.categoryRepository = categoryRepository; + this.publishMode = publishMode; + } public Post createPost(String username, Long categoryId, String title, String content) { User author = userRepository.findByUsername(username) @@ -28,17 +41,32 @@ public class PostService { post.setContent(content); post.setAuthor(author); post.setCategory(category); + post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); return postRepository.save(post); } public Post getPost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Post not found")); + if (post.getStatus() != PostStatus.PUBLISHED) { + throw new IllegalArgumentException("Post not found"); + } post.setViews(post.getViews() + 1); return postRepository.save(post); } public List listPosts() { - return postRepository.findAll(); + return postRepository.findByStatus(PostStatus.PUBLISHED); + } + + public List listPendingPosts() { + return postRepository.findByStatus(PostStatus.PENDING); + } + + public Post approvePost(Long id) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Post not found")); + post.setStatus(PostStatus.PUBLISHED); + return postRepository.save(post); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6cb56ee1b..d192237b9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,3 +16,6 @@ app.upload.max-size=${UPLOAD_MAX_SIZE:5242880} app.jwt.secret=${JWT_SECRET:ChangeThisSecretKeyForJwt} app.jwt.expiration=${JWT_EXPIRATION:86400000} + +# Post publish mode: DIRECT or REVIEW +app.post.publish-mode=${POST_PUBLISH_MODE:DIRECT} diff --git a/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java b/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java new file mode 100644 index 000000000..34319a13f --- /dev/null +++ b/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java @@ -0,0 +1,86 @@ +package com.openisle.integration; + +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.EmailSender; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.*; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** Integration tests for review publish mode. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "app.post.publish-mode=REVIEW") +class PublishModeIntegrationTest { + + @Autowired + private TestRestTemplate rest; + + @Autowired + private UserRepository users; + + @MockBean + private EmailSender emailService; + + private String registerAndLogin(String username, String email) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + rest.postForEntity("/api/auth/register", new HttpEntity<>( + Map.of("username", username, "email", email, "password", "pass"), h), Map.class); + User u = users.findByUsername(username).orElseThrow(); + rest.postForEntity("/api/auth/verify", new HttpEntity<>( + Map.of("username", username, "code", u.getVerificationCode()), h), Map.class); + ResponseEntity resp = rest.postForEntity("/api/auth/login", new HttpEntity<>( + Map.of("username", username, "password", "pass"), h), Map.class); + return (String) resp.getBody().get("token"); + } + + private String registerAndLoginAsAdmin(String username, String email) { + String token = registerAndLogin(username, email); + User u = users.findByUsername(username).orElseThrow(); + u.setRole(Role.ADMIN); + users.save(u); + return token; + } + + private ResponseEntity postJson(String url, Map body, String token) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + if (token != null) h.setBearerAuth(token); + return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); + } + + @Test + void postRequiresApproval() { + String userToken = registerAndLogin("eve", "e@example.com"); + String adminToken = registerAndLoginAsAdmin("admin", "admin@example.com"); + + ResponseEntity catResp = postJson("/api/categories", + Map.of("name", "review"), adminToken); + Long catId = ((Number)catResp.getBody().get("id")).longValue(); + + ResponseEntity postResp = postJson("/api/posts", + Map.of("title", "Need", "content", "Review", "categoryId", catId), userToken); + Long postId = ((Number)postResp.getBody().get("id")).longValue(); + + List list = rest.getForObject("/api/posts", List.class); + assertTrue(list.isEmpty(), "Post should not be listed before approval"); + + List> pending = (List>) rest.getForObject("/api/admin/posts/pending", List.class); + assertEquals(1, pending.size()); + assertEquals(postId.intValue(), ((Number)pending.get(0).get("id")).intValue()); + + postJson("/api/admin/posts/" + postId + "/approve", Map.of(), adminToken); + + List listAfter = rest.getForObject("/api/posts", List.class); + assertEquals(1, listAfter.size(), "Post should appear after approval"); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 489fa5f31..f41cda1cf 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -17,3 +17,6 @@ app.upload.max-size=1048576 app.jwt.secret=TestSecret app.jwt.expiration=3600000 + +# Default publish mode for tests +app.post.publish-mode=DIRECT