diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 52dd57c53..bcfd1a9f2 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -277,6 +277,43 @@ public ResponseEntity deleteExtension( } } + @PostMapping( + path = "/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Delete a review for an extension by a user") + @ApiResponse( + responseCode = "200", + description = "A success message is returned in JSON format", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + @ApiResponse( + responseCode = "404", + description = "Extension not found", + content = @Content() + ) + @ApiResponse( + responseCode = "404", + description = "Review not found", + content = @Content() + ) + public ResponseEntity deleteReview( + @PathVariable String namespace, + @PathVariable String extension, + @PathVariable String provider, + @PathVariable String loginName + ) { + try { + var adminUser = admins.checkAdminUser(); + var result = admins.deleteReview(namespace, extension, loginName, provider); + admins.logAdminAction(adminUser, result); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + @GetMapping( path = "/admin/namespace/{namespaceName}", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index 0aa4fca09..37a72e8f1 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -249,6 +249,43 @@ private String userNotFoundMessage(String user) { return "User not found: " + user; } + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson deleteReview(String namespace, String extensionName, String loginName, String provider) { + var extension = repositories.findExtension(extensionName, namespace); + if (extension == null || !extension.isActive()) { + var message = "Extension not found: " + NamingUtil.toExtensionId(namespace, extensionName); + throw new ErrorResultException(message, HttpStatus.NOT_FOUND); + } + + var user = repositories.findUserByLoginName(provider, loginName); + if (user == null) { + throw new ErrorResultException(userNotFoundMessage(provider + "/" + loginName), HttpStatus.NOT_FOUND); + } + + var reviews = repositories.findActiveReviews(extension, user); + if (reviews.isEmpty()) { + var message = "No active review for extension " + NamingUtil.toExtensionId(extension) + " and user " + loginName + " found"; + throw new ErrorResultException(message, HttpStatus.NOT_FOUND); + } + + for (var extReview : reviews) { + deleteReview(extReview); + } + + return ResultJson.success("Deleted review from " + loginName + " for " + NamingUtil.toExtensionId(extension)); + } + + private void deleteReview(ExtensionReview review) { + entityManager.remove(review); + + var extension = review.getExtension(); + extension.setAverageRating(repositories.getAverageReviewRating(extension)); + extension.setReviewCount(repositories.countActiveReviews(extension)); + search.updateSearchEntry(extension); + cache.evictExtensionJsons(extension); + cache.evictLatestExtensionVersion(extension); + } + @Transactional(rollbackOn = ErrorResultException.class) public ResultJson editNamespaceMember(String namespaceName, String userName, String provider, String role, UserData admin) throws ErrorResultException { @@ -402,8 +439,8 @@ public ResultJson revokePublisherContributions(String provider, String loginName } var result = ResultJson.success("Deactivated " + deactivatedTokenCount - + " tokens and deactivated " + deactivatedExtensionCount + " extensions of user " - + provider + "/" + loginName + "."); + + " tokens, deactivated " + deactivatedExtensionCount + " extensions of user " + + provider + "/" + loginName + "."); logAdminAction(admin, result); return result; } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionReviewRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionReviewRepository.java index 396cca8a9..5333893c5 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionReviewRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionReviewRepository.java @@ -27,6 +27,8 @@ public interface ExtensionReviewRepository extends Repository findByExtensionAndUserAndActiveTrue(Extension extension, UserData user); + Streamable findByUserAndActiveTrue(UserData user); + long countByExtensionAndActiveTrue(Extension extension); @Cacheable(CACHE_AVERAGE_REVIEW_RATING) diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index ec58a1611..941b39637 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -275,6 +275,10 @@ public Streamable findAllReviews(Extension extension) { return extensionReviewRepo.findByExtension(extension); } + public Streamable findActiveReviews(UserData user) { + return extensionReviewRepo.findByUserAndActiveTrue(user); + } + public Streamable findActiveReviews(Extension extension, UserData user) { return extensionReviewRepo.findByExtensionAndUserAndActiveTrue(extension, user); } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index a7869f504..19e76b8d0 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -52,16 +52,12 @@ import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Consumer; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -582,6 +578,7 @@ void testGetUserPublishInfo() throws Exception { Mockito.when(repositories.findUserByLoginName("github", "test")).thenReturn(user); Mockito.when(repositories.countActiveAccessTokens(user)).thenReturn(1L); Mockito.when(repositories.findLatestVersions(user)).thenReturn(versions); + Mockito.when(repositories.findActiveReviews(user)).thenReturn(Streamable.empty()); mockMvc.perform(get("/admin/publisher/{provider}/{loginName}", "github", "test") .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) @@ -634,11 +631,14 @@ void testRevokePublisherAgreement() throws Exception { Mockito.when(repositories.findVersionsByUser(user, true)) .thenReturn(Streamable.of(versions)); + Mockito.when(repositories.findActiveReviews(user)) + .thenReturn(Streamable.empty()); + mockMvc.perform(post("/admin/publisher/{provider}/{loginName}/revoke", "github", "test") .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) .with(csrf().asHeader())) .andExpect(status().isOk()) - .andExpect(content().json(successJson("Deactivated 1 tokens and deactivated 1 extensions of user github/test."))); + .andExpect(content().json(successJson("Deactivated 1 tokens, deactivated 1 extensions of user github/test."))); assertThat(token.isActive()).isFalse(); assertThat(versions.get(0).isActive()).isFalse(); @@ -1145,6 +1145,57 @@ void testChangeNamespaceAbortOnNewNamespaceExists() throws Exception { .andExpect(content().json(errorJson("New namespace already exists: bar"))); } + @Test + void testDeleteReview() throws Exception { + mockAdminUser(); + mockReviews(); + + mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foobar", "baz", "github", "user1") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted review from user1 for foobar.baz"))); + } + + @Test + void testDeleteReviewNotLoggedIn() throws Exception { + mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteReviewNormalUser() throws Exception { + mockNormalUser(); + + mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1") + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteReviewUnknownExtension() throws Exception { + mockAdminUser(); + mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foo", "bar", "github", "user1") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isNotFound()) + .andExpect(content().json(errorJson("Extension not found: foo.bar"))); + } + + @Test + void testDeleteReviewNonExistingReview() throws Exception { + mockAdminUser(); + mockReviews(); + + mockMvc.perform(post("/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", "foobar", "baz", "github", "user3") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isNotFound()) + .andExpect(content().json(errorJson("No active review for extension foobar.baz and user user3 found"))); + } + //---------- UTILITY ----------// private PersonalAccessToken mockAdminToken() { @@ -1290,6 +1341,56 @@ private List mockExtension(int numberOfVersions, int numberOfB return versions; } + private List mockReviews() { + var extVersions = mockExtension(1, 0, 0); + var extVersion = extVersions.get(0); + var extension = extVersion.getExtension(); + + var user1 = new UserData(); + user1.setLoginName("user1"); + var review1 = new ExtensionReview(); + review1.setId(1); + review1.setExtension(extension); + review1.setUser(user1); + review1.setRating(3); + review1.setComment("Somewhat ok"); + review1.setTimestamp(LocalDateTime.parse("2000-01-01T10:00")); + review1.setActive(true); + + var user2 = new UserData(); + user2.setLoginName("user2"); + var review2 = new ExtensionReview(); + review2.setId(2); + review2.setExtension(extension); + review2.setUser(user2); + review2.setRating(4); + review2.setComment("Quite good"); + review2.setTimestamp(LocalDateTime.parse("2000-01-01T10:00")); + review2.setActive(true); + + var user3 = new UserData(); + user3.setLoginName("user3"); + + Mockito.when(repositories.findUserByLoginName(anyString(), eq("user1"))) + .thenReturn(user1); + Mockito.when(repositories.findUserByLoginName(anyString(), eq("user2"))) + .thenReturn(user2); + Mockito.when(repositories.findUserByLoginName(anyString(), eq("user3"))) + .thenReturn(user3); + + Mockito.when(repositories.findActiveReviews(any(), any())) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findActiveReviews(extension, user1)) + .thenReturn(Streamable.of(review1)); + Mockito.when(repositories.findActiveReviews(extension, user2)) + .thenReturn(Streamable.of(review2)); + + Mockito.when(repositories.findActiveReviews(extension)) + .thenReturn(Streamable.of(review1, review2)); + + return List.of(review1, review2); + } + private String createVersion(int major) { return major + ".0.0"; } diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index ab4dfc322..d39cd36cc 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -100,6 +100,7 @@ void testExecuteQueries() { () -> repositories.findActiveExtensions(namespace), () -> repositories.findActiveReviews(extension), () -> repositories.findActiveReviews(extension, userData), + () -> repositories.findActiveReviews(userData), () -> repositories.findActiveVersions(extension), () -> repositories.findAdminStatisticsByYearAndMonth(1997, 1), () -> repositories.findAllActiveExtensions(), diff --git a/webui/src/components/button-with-progress.tsx b/webui/src/components/button-with-progress.tsx index a400df226..0b6f63666 100644 --- a/webui/src/components/button-with-progress.tsx +++ b/webui/src/components/button-with-progress.tsx @@ -15,7 +15,7 @@ export const ButtonWithProgress: FunctionComponent + setRemoveDialogOpen(false)}> + Remove Review + + + Confirm removal of review comment from {r.user.loginName}? + + + + + handleAdminRemoveReviewButton(r)} > + Remove review + + + + ; + }; + const renderReviewList = (list?: ExtensionReviewList): ReactNode => { if (!list) { return ''; @@ -101,37 +177,47 @@ export const ExtensionDetailReviews: FunctionComponent { return - - - { - r.timestamp ? - <> - - - - : null - } - + + + { - r.user.homepage ? - - {r.user.loginName} - - : - r.user.loginName + r.timestamp ? + <> + + + + : null } - - - - - - - {r.comment} + + { + r.user.homepage ? + + {r.user.loginName} + + : + r.user.loginName + } + + + + + + + {r.comment} + + { + context.user?.role === 'admin' ? + + {renderAdminRemoveButton(r)} + + : + null + } ; diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index 4513e117f..fce387a40 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -21,7 +21,7 @@ import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { HoverPopover } from '../../components/hover-popover'; import { Extension, UserData, isError } from '../../extension-registry-types'; import { TextDivider } from '../../components/text-divider'; -import { ExportRatingStars } from './extension-rating-stars'; +import { ExtensionRatingStars } from './extension-rating-stars'; import { NamespaceDetailRoutes } from '../namespace-detail/namespace-detail'; import { ExtensionDetailOverview } from './extension-detail-overview'; import { ExtensionDetailChanges } from './extension-detail-changes'; @@ -385,7 +385,7 @@ export const ExtensionDetail: FunctionComponent = () => { `Average rating: ${getRoundedRating(extension.averageRating)} out of 5 (${extension.reviewCount} reviews)` : 'Not rated yet' }> - + ({reviewCountFormatted}) diff --git a/webui/src/pages/extension-detail/extension-rating-stars.tsx b/webui/src/pages/extension-detail/extension-rating-stars.tsx index 17334faaa..0c2b31cf8 100644 --- a/webui/src/pages/extension-detail/extension-rating-stars.tsx +++ b/webui/src/pages/extension-detail/extension-rating-stars.tsx @@ -13,12 +13,12 @@ import StarIcon from '@mui/icons-material/Star'; import StarHalfIcon from '@mui/icons-material/StarHalf'; import { Box } from '@mui/material'; -export interface ExportRatingStarsProps { +export interface ExtensionRatingStarsProps { number: number; fontSize?: 'inherit' | 'small' | 'medium' | 'large'; } -export const ExportRatingStars: FunctionComponent = props => { +export const ExtensionRatingStars: FunctionComponent = props => { const getStar = (i: number): ReactNode => { const starsNumber = props.number; const fontSize = props.fontSize ?? 'medium'; diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx index 7c0b69fd2..db58ddfa2 100644 --- a/webui/src/pages/extension-list/extension-list-item.tsx +++ b/webui/src/pages/extension-list/extension-list-item.tsx @@ -15,7 +15,7 @@ import SaveAltIcon from '@mui/icons-material/SaveAlt'; import { MainContext } from '../../context'; import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; import { SearchEntry } from '../../extension-registry-types'; -import { ExportRatingStars } from '../extension-detail/extension-rating-stars'; +import { ExtensionRatingStars } from '../extension-detail/extension-rating-stars'; import { createRoute } from '../../utils'; export const ExtensionListItem: FunctionComponent = props => { @@ -92,7 +92,7 @@ export const ExtensionListItem: FunctionComponent = prop - +   {downloadCountFormatted != "0" && <> {downloadCountFormatted}}