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 bcfd1a9f2..35b0685ba 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -469,6 +469,63 @@ public ResponseEntity editNamespaceMember( } } + @DeleteMapping( + path = "/admin/namespace/{namespaceName}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Delete a namespace and all its extensions") + @ApiResponse( + responseCode = "200", + description = "The namespace was successfully deleted", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + @ApiResponse( + responseCode = "404", + description = "Namespace not found", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + public ResponseEntity deleteNamespace( + @PathVariable @Parameter(description = "Namespace name", example = "my-namespace") String namespaceName + ) { + try { + var adminUser = admins.checkAdminUser(); + var result = admins.deleteNamespace(namespaceName, adminUser); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + + @DeleteMapping( + path = "/admin/api/namespace/{namespaceName}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @CrossOrigin + @Operation(summary = "Delete a namespace and all its extensions (with token authentication)") + @ApiResponse( + responseCode = "200", + description = "The namespace was successfully deleted", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + @ApiResponse( + responseCode = "404", + description = "Namespace not found", + content = @Content(schema = @Schema(implementation = ResultJson.class)) + ) + public ResponseEntity deleteNamespaceWithToken( + @PathVariable @Parameter(description = "Namespace name", example = "my-namespace") String namespaceName, + @RequestParam(value = "token") @Parameter(description = "A personal access token") String tokenValue + ) { + try { + var adminUser = admins.checkAdminUser(tokenValue); + var result = admins.deleteNamespace(namespaceName, adminUser); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + @GetMapping( path = "/admin/publisher/{provider}/{loginName}", 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 37a72e8f1..ef90edfcd 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -324,6 +324,67 @@ public ResultJson createNamespace(NamespaceJson json) { return ResultJson.success("Created namespace " + namespace.getName()); } + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson deleteNamespace(String namespaceName, UserData admin) { + var namespace = repositories.findNamespace(namespaceName); + if (namespace == null) { + throw new ErrorResultException("Namespace not found: " + namespaceName, HttpStatus.NOT_FOUND); + } + + // Get all extensions in the namespace + var extensions = repositories.findExtensions(namespace); + + // Delete all extensions and their versions + for (var extension : extensions) { + // Remove reviews (associated with extension, not version) + var reviews = repositories.findAllReviews(extension); + for (var review : reviews) { + entityManager.remove(review); + } + + // Remove all versions and their resources + var versions = repositories.findVersions(extension); + for (var version : versions) { + // Remove file resources + var resources = repositories.findFiles(version); + for (var resource : resources) { + storageUtil.removeFile(resource); + entityManager.remove(resource); + } + + // Remove the version + entityManager.remove(version); + } + + // Clear cache for the extension + cache.evictExtensionJsons(extension); + cache.evictLatestExtensionVersion(extension); + + // Remove the extension + entityManager.remove(extension); + } + + // Remove all namespace memberships + var memberships = repositories.findMemberships(namespace); + for (var membership : memberships) { + entityManager.remove(membership); + } + + // Clear cache for the namespace + cache.evictNamespaceDetails(namespace); + cache.evictSitemap(); + + // Remove the namespace + entityManager.remove(namespace); + + // Update search index + search.updateSearchEntries(extensions.toList()); + + var result = ResultJson.success("Deleted namespace " + namespaceName + " with " + extensions.toList().size() + " extension(s)"); + logAdminAction(admin, result); + return result; + } + public void changeNamespace(ChangeNamespaceJson json) { if (StringUtils.isEmpty(json.oldNamespace())) { throw new ErrorResultException("Old namespace must have a value"); 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 312bbc832..fdfa65df7 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -546,6 +546,357 @@ void testCreateExistingNamespace() throws Exception { .andExpect(content().json(errorJson("Namespace already exists: foobar"))); } + @Test + void testDeleteNamespaceNotLoggedIn() throws Exception { + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteNamespaceNotAdmin() throws Exception { + mockNormalUser(); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteNamespace() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar with 0 extension(s)"))); + } + + @Test + void testDeleteNonExistentNamespace() throws Exception { + mockAdminUser(); + Mockito.when(repositories.findNamespace("nonexistent")) + .thenReturn(null); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "nonexistent") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isNotFound()) + .andExpect(content().json(errorJson("Namespace not found: nonexistent"))); + } + + @Test + void testDeleteNamespaceWithExtensions() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + + // Mock extensions with versions + var extension = new Extension(); + extension.setName("test-ext"); + extension.setNamespace(namespace); + + var version1 = new ExtensionVersion(); + version1.setId(1L); + version1.setVersion("1.0.0"); + version1.setExtension(extension); + + var version2 = new ExtensionVersion(); + version2.setId(2L); + version2.setVersion("2.0.0"); + version2.setExtension(extension); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension)); + Mockito.when(repositories.findVersions(extension)) + .thenReturn(Streamable.of(version1, version2)); + Mockito.when(repositories.findFiles(any(ExtensionVersion.class))) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findAllReviews(extension)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar with 1 extension(s)"))); + + // Verify that extension versions were deleted + Mockito.verify(entityManager, Mockito.times(2)).remove(any(ExtensionVersion.class)); + // Verify that extension was deleted + Mockito.verify(entityManager).remove(extension); + // Verify that namespace was deleted + Mockito.verify(entityManager).remove(namespace); + } + + @Test + void testDeleteNamespaceWithMemberships() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + + var user1 = new UserData(); + user1.setLoginName("user1"); + var membership1 = new NamespaceMembership(); + membership1.setNamespace(namespace); + membership1.setUser(user1); + membership1.setRole(NamespaceMembership.ROLE_OWNER); + + var user2 = new UserData(); + user2.setLoginName("user2"); + var membership2 = new NamespaceMembership(); + membership2.setNamespace(namespace); + membership2.setUser(user2); + membership2.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Arrays.asList(membership1, membership2)); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar with 0 extension(s)"))); + + // Verify memberships were deleted + Mockito.verify(entityManager).remove(membership1); + Mockito.verify(entityManager).remove(membership2); + Mockito.verify(entityManager).remove(namespace); + } + + @Test + void testDeleteNamespaceWithFileResources() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + + var extension = new Extension(); + extension.setName("test-ext"); + extension.setNamespace(namespace); + + var version = new ExtensionVersion(); + version.setId(1L); + version.setVersion("1.0.0"); + version.setExtension(extension); + + var fileResource1 = new FileResource(); + fileResource1.setId(1L); + fileResource1.setExtension(version); + fileResource1.setType(FileResource.DOWNLOAD); + + var fileResource2 = new FileResource(); + fileResource2.setId(2L); + fileResource2.setExtension(version); + fileResource2.setType(FileResource.MANIFEST); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension)); + Mockito.when(repositories.findVersions(extension)) + .thenReturn(Streamable.of(version)); + Mockito.when(repositories.findFiles(version)) + .thenReturn(Streamable.of(fileResource1, fileResource2)); + Mockito.when(repositories.findAllReviews(extension)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()); + + // Verify files were removed from storage and database + Mockito.verify(entityManager).remove(fileResource1); + Mockito.verify(entityManager).remove(fileResource2); + } + + @Test + void testDeleteNamespaceWithReviews() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + + var extension = new Extension(); + extension.setName("test-ext"); + extension.setNamespace(namespace); + + var version = new ExtensionVersion(); + version.setId(1L); + version.setVersion("1.0.0"); + version.setExtension(extension); + + var user1 = new UserData(); + user1.setLoginName("user1"); + var review1 = new ExtensionReview(); + review1.setId(1L); + review1.setExtension(extension); + review1.setUser(user1); + + var user2 = new UserData(); + user2.setLoginName("user2"); + var review2 = new ExtensionReview(); + review2.setId(2L); + review2.setExtension(extension); + review2.setUser(user2); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension)); + Mockito.when(repositories.findVersions(extension)) + .thenReturn(Streamable.of(version)); + Mockito.when(repositories.findFiles(version)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findAllReviews(extension)) + .thenReturn(Streamable.of(review1, review2)); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()); + + // Verify reviews were deleted (should be called for each version) + Mockito.verify(entityManager, Mockito.atLeastOnce()).remove(review1); + Mockito.verify(entityManager, Mockito.atLeastOnce()).remove(review2); + } + + @Test + void testDeleteNamespaceWithMultipleExtensions() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + + var extension1 = new Extension(); + extension1.setName("ext1"); + extension1.setNamespace(namespace); + + var extension2 = new Extension(); + extension2.setName("ext2"); + extension2.setNamespace(namespace); + + var extension3 = new Extension(); + extension3.setName("ext3"); + extension3.setNamespace(namespace); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension1, extension2, extension3)); + Mockito.when(repositories.findVersions(any(Extension.class))) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findAllReviews(any(Extension.class))) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar with 3 extension(s)"))); + + // Verify all extensions were deleted + Mockito.verify(entityManager).remove(extension1); + Mockito.verify(entityManager).remove(extension2); + Mockito.verify(entityManager).remove(extension3); + } + + @Test + void testDeleteNamespaceComplexScenario() throws Exception { + mockAdminUser(); + var namespace = mockNamespace(); + + // Create extension with multiple versions, files, and reviews + var extension = new Extension(); + extension.setName("complex-ext"); + extension.setNamespace(namespace); + + var version1 = new ExtensionVersion(); + version1.setId(1L); + version1.setVersion("1.0.0"); + version1.setExtension(extension); + + var version2 = new ExtensionVersion(); + version2.setId(2L); + version2.setVersion("2.0.0"); + version2.setExtension(extension); + + var file1 = new FileResource(); + file1.setId(1L); + file1.setExtension(version1); + + var file2 = new FileResource(); + file2.setId(2L); + file2.setExtension(version2); + + var review = new ExtensionReview(); + review.setId(1L); + review.setExtension(extension); + + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.of(extension)); + Mockito.when(repositories.findVersions(extension)) + .thenReturn(Streamable.of(version1, version2)); + Mockito.when(repositories.findFiles(version1)) + .thenReturn(Streamable.of(file1)); + Mockito.when(repositories.findFiles(version2)) + .thenReturn(Streamable.of(file2)); + Mockito.when(repositories.findAllReviews(extension)) + .thenReturn(Streamable.of(review)); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Arrays.asList(membership)); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar with 1 extension(s)"))); + + // Verify complete cleanup + Mockito.verify(entityManager).remove(file1); + Mockito.verify(entityManager).remove(file2); + Mockito.verify(entityManager, Mockito.atLeastOnce()).remove(review); + Mockito.verify(entityManager).remove(version1); + Mockito.verify(entityManager).remove(version2); + Mockito.verify(entityManager).remove(extension); + Mockito.verify(entityManager).remove(membership); + Mockito.verify(entityManager).remove(namespace); + } + + @Test + void testDeleteNamespaceWithToken() throws Exception { + var token = mockAdminToken(); + var namespace = mockNamespace(); + + Mockito.when(repositories.findExtensions(namespace)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findMemberships(namespace)) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .param("token", "admin_token") + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted namespace foobar with 0 extension(s)"))); + } + + @Test + void testDeleteNamespaceWithInvalidToken() throws Exception { + var token = mockNonAdminToken(); + var namespace = mockNamespace(); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/admin/namespace/{namespaceName}", "foobar") + .param("token", "normal_token") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + @Test void testGetUserPublishInfoNotLoggedIn() throws Exception { mockNamespace(); diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index c90357ba0..b56616b21 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -482,6 +482,7 @@ export interface AdminService { deleteExtensions(abortController: AbortController, req: { namespace: string, extension: string, targetPlatformVersions?: object[] }): Promise> getNamespace(abortController: AbortController, name: string): Promise> createNamespace(abortController: AbortController, namespace: { name: string }): Promise> + deleteNamespace(abortController: AbortController, name: string): Promise> changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> @@ -551,6 +552,22 @@ export class AdminServiceImpl implements AdminService { }); } + async deleteNamespace(abortController: AbortController, name: string): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = {}; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'namespace', name]), + method: 'DELETE', + headers + }); + } + async changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> { const csrfResponse = await this.registry.getCsrfToken(abortController); const headers: Record = { diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index 68f752863..b754c38ed 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -9,16 +9,18 @@ ********************************************************************************/ import React, { FunctionComponent, createContext, useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Box, Button, Link, Paper, Grid, Typography } from '@mui/material'; import { styled, Theme } from '@mui/material/styles'; import WarningIcon from '@mui/icons-material/Warning'; import { UserNamespaceExtensionListContainer } from './user-namespace-extension-list'; import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard'; -import { Namespace, UserData } from '../../extension-registry-types'; +import { Namespace, UserData, isError } from '../../extension-registry-types'; import { NamespaceChangeDialog } from '../admin-dashboard/namespace-change-dialog'; import { UserNamespaceMemberList } from './user-namespace-member-list'; import { UserNamespaceDetails } from './user-namespace-details'; +import { MainContext } from '../../context'; +import { ButtonWithProgress } from '../../components/button-with-progress'; export interface NamespaceDetailConfig { defaultMemberRole?: 'contributor' | 'owner'; @@ -59,7 +61,10 @@ const NamespaceHeader = styled(Box)(({ theme }: { theme: Theme }) => ({ export const NamespaceDetail: FunctionComponent = props => { const [changeDialogIsOpen, setChangeDialogIsOpen] = useState(false); + const [deleting, setDeleting] = useState(false); const { pathname } = useLocation(); + const navigate = useNavigate(); + const { service, handleError } = React.useContext(MainContext); const handleCloseChangeDialog = async () => { setChangeDialogIsOpen(false); @@ -68,6 +73,31 @@ export const NamespaceDetail: FunctionComponent = props => setChangeDialogIsOpen(true); }; + const handleDelete = async () => { + if (!window.confirm(`Are you sure you want to delete the namespace "${props.namespace.name}" and all its extensions? This action cannot be undone.`)) { + return; + } + + try { + setDeleting(true); + props.setLoadingState(true); + const abortController = new AbortController(); + const result = await service.admin.deleteNamespace(abortController, props.namespace.name); + + if (isError(result)) { + throw result; + } + + // Navigate back to namespace admin page after successful deletion + navigate(AdminDashboardRoutes.NAMESPACE_ADMIN); + } catch (err) { + handleError(err); + } finally { + setDeleting(false); + props.setLoadingState(false); + } + }; + const warningColor = props.theme === 'dark' ? '#fff' : '#151515'; return <> @@ -99,9 +129,18 @@ export const NamespaceDetail: FunctionComponent = props => {props.namespace.name} { pathname.startsWith(AdminDashboardRoutes.NAMESPACE_ADMIN) - ? + ? + + + Delete Namespace + + : null }