From a84764ebcf565c2a52aca486bfb2f5368f8f2827 Mon Sep 17 00:00:00 2001 From: amvanbaren Date: Tue, 14 Oct 2025 11:14:11 +0300 Subject: [PATCH] User settings extension delete feature --- .../org/eclipse/openvsx/ExtensionService.java | 99 ++++++- .../java/org/eclipse/openvsx/UserAPI.java | 57 +++- .../org/eclipse/openvsx/admin/AdminAPI.java | 34 +-- .../eclipse/openvsx/admin/AdminService.java | 32 ++- .../ExtensionVersionJooqRepository.java | 190 +++++++++++++ .../ExtensionVersionRepository.java | 4 +- .../repositories/RepositoryService.java | 21 +- .../org/eclipse/openvsx/RegistryAPITest.java | 7 +- .../java/org/eclipse/openvsx/UserAPITest.java | 269 +++++++++++++++++- .../eclipse/openvsx/admin/AdminAPITest.java | 28 +- .../openvsx/eclipse/EclipseServiceTest.java | 10 +- .../RepositoryServiceSmokeTest.java | 6 +- webui/package.json | 2 +- webui/src/extension-registry-service.ts | 37 +++ webui/src/other-pages.tsx | 1 + .../pages/admin-dashboard/extension-admin.tsx | 13 +- .../extension-remove-dialog.tsx | 37 +-- .../extension-version-container.tsx | 6 +- webui/src/pages/user/user-extension-list.tsx | 2 + .../user-namespace-extension-list-item.tsx | 29 +- .../user/user-namespace-extension-list.tsx | 2 +- .../user/user-settings-delete-extension.tsx | 81 ++++++ .../pages/user/user-settings-extensions.tsx | 2 +- webui/src/pages/user/user-settings.tsx | 12 +- 24 files changed, 866 insertions(+), 115 deletions(-) create mode 100644 webui/src/pages/user/user-settings-delete-extension.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java index 12eab7903..d968c22d6 100644 --- a/server/src/main/java/org/eclipse/openvsx/ExtensionService.java +++ b/server/src/main/java/org/eclipse/openvsx/ExtensionService.java @@ -9,17 +9,23 @@ ********************************************************************************/ package org.eclipse.openvsx; +import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; import jakarta.transaction.Transactional.TxType; import org.apache.commons.lang3.StringUtils; +import org.eclipse.openvsx.admin.RemoveFileJobRequest; import org.eclipse.openvsx.cache.CacheService; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.NamingUtil; import org.eclipse.openvsx.util.TempFile; import org.eclipse.openvsx.util.TimeUtil; +import org.jobrunr.scheduling.JobRequestScheduler; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -30,31 +36,41 @@ import java.io.InputStream; import java.nio.file.Files; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; @Component public class ExtensionService { private static final int MAX_CONTENT_SIZE = 512 * 1024 * 1024; + private final EntityManager entityManager; private final RepositoryService repositories; private final SearchUtilService search; private final CacheService cache; private final PublishExtensionVersionHandler publishHandler; + private final JobRequestScheduler scheduler; @Value("${ovsx.publishing.require-license:false}") boolean requireLicense; public ExtensionService( + EntityManager entityManager, RepositoryService repositories, SearchUtilService search, CacheService cache, - PublishExtensionVersionHandler publishHandler + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler ) { + this.entityManager = entityManager; this.repositories = repositories; this.search = search; this.cache = cache; this.publishHandler = publishHandler; + this.scheduler = scheduler; } @Transactional @@ -152,4 +168,85 @@ public void reactivateExtensions(UserData user) { updateExtension(extension); } } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson deleteExtension( + String namespaceName, + String extensionName, + List targetVersions, + UserData user + ) throws ErrorResultException { + var results = new ArrayList(); + if(repositories.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user)) { + var extension = repositories.findExtension(extensionName, namespaceName); + results.add(deleteExtension(extension)); + } else { + for (var targetVersion : targetVersions) { + var extVersion = repositories.findVersion(user, targetVersion.version(), targetVersion.targetPlatform(), extensionName, namespaceName); + if (extVersion == null) { + var message = "Extension not found: " + NamingUtil.toLogFormat(namespaceName, extensionName, targetVersion.targetPlatform(), targetVersion.version()); + throw new ErrorResultException(message, HttpStatus.NOT_FOUND); + } + + results.add(deleteExtension(extVersion)); + } + } + + var result = new ResultJson(); + result.setError(results.stream().map(ResultJson::getError).filter(Objects::nonNull).collect(Collectors.joining("\n"))); + result.setSuccess(results.stream().map(ResultJson::getSuccess).filter(Objects::nonNull).collect(Collectors.joining("\n"))); + return result; + } + + protected ResultJson deleteExtension(Extension extension) throws ErrorResultException { + var bundledRefs = repositories.findBundledExtensionsReference(extension); + if (!bundledRefs.isEmpty()) { + throw new ErrorResultException("Extension " + NamingUtil.toExtensionId(extension) + + " is bundled by the following extension packs: " + + bundledRefs.stream() + .map(NamingUtil::toFileFormat) + .collect(Collectors.joining(", "))); + } + var dependRefs = repositories.findDependenciesReference(extension); + if (!dependRefs.isEmpty()) { + throw new ErrorResultException("The following extensions have a dependency on " + NamingUtil.toExtensionId(extension) + ": " + + dependRefs.stream() + .map(NamingUtil::toFileFormat) + .collect(Collectors.joining(", "))); + } + + cache.evictExtensionJsons(extension); + for (var extVersion : repositories.findVersions(extension)) { + removeExtensionVersion(extVersion); + } + for (var review : repositories.findAllReviews(extension)) { + entityManager.remove(review); + } + + var deprecatedExtensions = repositories.findDeprecatedExtensions(extension); + for(var deprecatedExtension : deprecatedExtensions) { + deprecatedExtension.setReplacement(null); + cache.evictExtensionJsons(deprecatedExtension); + } + + entityManager.remove(extension); + search.removeSearchEntry(extension); + + return ResultJson.success("Deleted " + NamingUtil.toExtensionId(extension)); + } + + protected ResultJson deleteExtension(ExtensionVersion extVersion) { + var extension = extVersion.getExtension(); + removeExtensionVersion(extVersion); + extension.getVersions().remove(extVersion); + updateExtension(extension); + + return ResultJson.success("Deleted " + NamingUtil.toLogFormat(extVersion)); + } + + private void removeExtensionVersion(ExtensionVersion extVersion) { + repositories.findFiles(extVersion).map(RemoveFileJobRequest::new).forEach(scheduler::enqueue); + repositories.deleteFiles(extVersion); + entityManager.remove(extVersion); + } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 8ae378610..d36ac611f 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -18,6 +18,7 @@ import org.eclipse.openvsx.security.CodedAuthException; import org.eclipse.openvsx.storage.StorageUtilService; import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.NamingUtil; import org.eclipse.openvsx.util.NotFoundException; import org.eclipse.openvsx.util.UrlUtil; import org.slf4j.Logger; @@ -51,17 +52,23 @@ public class UserAPI { private final UserService users; private final EclipseService eclipse; private final StorageUtilService storageUtil; + private final LocalRegistryService local; + private final ExtensionService extensions; public UserAPI( RepositoryService repositories, UserService users, EclipseService eclipse, - StorageUtilService storageUtil + StorageUtilService storageUtil, + LocalRegistryService local, + ExtensionService extensions ) { this.repositories = repositories; this.users = users; this.eclipse = eclipse; this.storageUtil = storageUtil; + this.local = local; + this.extensions = extensions; } @GetMapping( @@ -206,6 +213,54 @@ public List getOwnExtensions() { .toList(); } + @GetMapping( + path = "/user/extension/{namespaceName}/{extensionName}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getOwnExtension(@PathVariable String namespaceName, @PathVariable String extensionName) { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + try { + ExtensionJson json; + var latest = repositories.findLatestVersion(user, namespaceName, extensionName); + if (latest != null) { + json = local.toExtensionVersionJson(latest, null, false); + json.setAllTargetPlatformVersions(repositories.findTargetPlatformsGroupedByVersion(latest.getExtension(), user)); + json.setActive(latest.getExtension().isActive()); + } else { + var error = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName); + throw new ErrorResultException(error, HttpStatus.NOT_FOUND); + } + return ResponseEntity.ok(json); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(ExtensionJson.class); + } + } + + @PostMapping( + path = "/user/extension/{namespaceName}/{extensionName}/delete", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity deleteExtension( + @PathVariable String namespaceName, + @PathVariable String extensionName, + @RequestBody List targetVersions + ) { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + try { + var result = extensions.deleteExtension(namespaceName, extensionName, targetVersions, user); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } + } + @GetMapping( path = "/user/namespaces", produces = MediaType.APPLICATION_JSON_VALUE 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 901dbcc4f..52dd57c53 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -19,7 +19,6 @@ import org.eclipse.openvsx.entities.AdminStatistics; import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersistedLog; -import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; @@ -34,10 +33,8 @@ import java.net.URI; import java.time.Period; import java.time.format.DateTimeParseException; -import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; @RestController @@ -255,7 +252,8 @@ public ResponseEntity deleteExtension( ) { try { var adminUser = admins.checkAdminUser(tokenValue); - return deleteExtension(adminUser, namespaceName, extensionName, targetVersions); + var result = admins.deleteExtension(adminUser, namespaceName, extensionName, targetVersions); + return ResponseEntity.ok(result); } catch (ErrorResultException exc) { return exc.toResponseEntity(); } @@ -268,39 +266,17 @@ public ResponseEntity deleteExtension( public ResponseEntity deleteExtension( @PathVariable String namespaceName, @PathVariable String extensionName, - @RequestBody(required = false) List targetVersions + @RequestBody List targetVersions ) { try { var adminUser = admins.checkAdminUser(); - return deleteExtension(adminUser, namespaceName, extensionName, targetVersions); + var result = admins.deleteExtension(adminUser, namespaceName, extensionName, targetVersions); + return ResponseEntity.ok(result); } catch (ErrorResultException exc) { return exc.toResponseEntity(); } } - private ResponseEntity deleteExtension( - UserData adminUser, - String namespaceName, - String extensionName, - List targetVersions - ) { - ResultJson result; - if(targetVersions == null) { - result = admins.deleteExtension(namespaceName, extensionName, adminUser); - } else { - var results = new ArrayList(); - for(var targetVersion : targetVersions) { - results.add(admins.deleteExtension(namespaceName, extensionName, targetVersion.targetPlatform(), targetVersion.version(), adminUser)); - } - - result = new ResultJson(); - result.setError(results.stream().map(ResultJson::getError).filter(Objects::nonNull).collect(Collectors.joining("\n"))); - result.setSuccess(results.stream().map(ResultJson::getSuccess).filter(Objects::nonNull).collect(Collectors.joining("\n"))); - } - - return ResponseEntity.ok(result); - } - @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 35d4aa3ee..0aa4fca09 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -33,9 +33,7 @@ import org.springframework.stereotype.Component; import java.time.ZoneId; -import java.util.Comparator; -import java.util.LinkedHashSet; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import static org.eclipse.openvsx.entities.FileResource.*; @@ -134,7 +132,7 @@ public void deleteExtensionAndDependencies(Extension extension, UserData admin, protected void deleteExtensionAndDependencies(ExtensionVersion extVersion, UserData admin, int depth) { var extension = extVersion.getExtension(); - if (repositories.countVersions(extension) == 1) { + if (repositories.countVersions(extension.getNamespace().getName(), extension.getName()) == 1) { deleteExtensionAndDependencies(extension, admin, depth + 1); return; } @@ -145,6 +143,28 @@ protected void deleteExtensionAndDependencies(ExtensionVersion extVersion, UserD logAdminAction(admin, ResultJson.success("Deleted " + NamingUtil.toLogFormat(extVersion))); } + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson deleteExtension( + UserData adminUser, + String namespaceName, + String extensionName, + List targetVersions + ) { + if(targetVersions == null || repositories.countVersions(namespaceName, extensionName) == targetVersions.size()) { + return deleteExtension(namespaceName, extensionName, adminUser); + } + + var results = new ArrayList(); + for(var targetVersion : targetVersions) { + results.add(deleteExtension(namespaceName, extensionName, targetVersion.targetPlatform(), targetVersion.version(), adminUser)); + } + + var result = new ResultJson(); + result.setError(results.stream().map(ResultJson::getError).filter(Objects::nonNull).collect(Collectors.joining("\n"))); + result.setSuccess(results.stream().map(ResultJson::getSuccess).filter(Objects::nonNull).collect(Collectors.joining("\n"))); + return result; + } + @Transactional(rollbackOn = ErrorResultException.class) public ResultJson deleteExtension(String namespaceName, String extensionName, UserData admin) throws ErrorResultException { @@ -210,10 +230,6 @@ protected ResultJson deleteExtension(Extension extension, UserData admin) throws protected ResultJson deleteExtension(ExtensionVersion extVersion, UserData admin) { var extension = extVersion.getExtension(); - if (repositories.countVersions(extension) == 1) { - return deleteExtension(extension, admin); - } - removeExtensionVersion(extVersion); extension.getVersions().remove(extVersion); extensions.updateExtension(extension); diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java index a722cb29a..4cd97c3f6 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; +import org.eclipse.openvsx.json.TargetPlatformVersionJson; import org.eclipse.openvsx.json.VersionTargetPlatformsJson; import org.eclipse.openvsx.util.TargetPlatform; import org.eclipse.openvsx.util.VersionAlias; @@ -576,6 +577,46 @@ public List findTargetPlatformsGroupedByVersion(Exte )); } + public List findTargetPlatformsGroupedByVersion(Extension extension, UserData user) { + var targetPlatforms = DSL.arrayAgg(EXTENSION_VERSION.TARGET_PLATFORM) + .orderBy( + EXTENSION_VERSION.UNIVERSAL_TARGET_PLATFORM.desc(), + EXTENSION_VERSION.TARGET_PLATFORM.asc() + ); + + return dsl.select( + EXTENSION_VERSION.SEMVER_MAJOR, + EXTENSION_VERSION.SEMVER_MINOR, + EXTENSION_VERSION.SEMVER_PATCH, + EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE, + EXTENSION_VERSION.VERSION, + targetPlatforms + ) + .from(EXTENSION_VERSION) + .join(PERSONAL_ACCESS_TOKEN).on(PERSONAL_ACCESS_TOKEN.ID.eq(EXTENSION_VERSION.PUBLISHED_WITH_ID)) + .where(EXTENSION_VERSION.EXTENSION_ID.eq(extension.getId())) + .and(PERSONAL_ACCESS_TOKEN.USER_DATA.eq(user.getId())) + .groupBy( + EXTENSION_VERSION.SEMVER_MAJOR, + EXTENSION_VERSION.SEMVER_MINOR, + EXTENSION_VERSION.SEMVER_PATCH, + EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE, + EXTENSION_VERSION.VERSION + ) + .orderBy( + EXTENSION_VERSION.SEMVER_MAJOR.desc(), + EXTENSION_VERSION.SEMVER_MINOR.desc(), + EXTENSION_VERSION.SEMVER_PATCH.desc(), + EXTENSION_VERSION.SEMVER_IS_PRE_RELEASE.asc(), + EXTENSION_VERSION.VERSION.asc() + ) + .fetch() + .map(row -> new VersionTargetPlatformsJson( + row.get(EXTENSION_VERSION.VERSION), + row.get(targetPlatforms) + )); + } + public List findVersionsForUrls(Extension extension, String targetPlatform, String version) { var query = dsl.selectQuery(); query.addSelect( @@ -1050,6 +1091,112 @@ public List findLatest(UserData user) { }); } + public ExtensionVersion findLatest(UserData user, String namespace, String extension) { + var latestQuery = findLatestQuery(null, false, false); + latestQuery.addSelect( + EXTENSION_VERSION.ID, + EXTENSION_VERSION.VERSION, + EXTENSION_VERSION.POTENTIALLY_MALICIOUS, + EXTENSION_VERSION.TARGET_PLATFORM, + EXTENSION_VERSION.PREVIEW, + EXTENSION_VERSION.PRE_RELEASE, + EXTENSION_VERSION.TIMESTAMP, + EXTENSION_VERSION.DISPLAY_NAME, + EXTENSION_VERSION.DESCRIPTION, + EXTENSION_VERSION.ENGINES, + EXTENSION_VERSION.CATEGORIES, + EXTENSION_VERSION.TAGS, + EXTENSION_VERSION.EXTENSION_KIND, + EXTENSION_VERSION.LICENSE, + EXTENSION_VERSION.HOMEPAGE, + EXTENSION_VERSION.REPOSITORY, + EXTENSION_VERSION.SPONSOR_LINK, + EXTENSION_VERSION.BUGS, + EXTENSION_VERSION.MARKDOWN, + EXTENSION_VERSION.GALLERY_COLOR, + EXTENSION_VERSION.GALLERY_THEME, + EXTENSION_VERSION.LOCALIZED_LANGUAGES, + EXTENSION_VERSION.QNA, + EXTENSION_VERSION.DEPENDENCIES, + EXTENSION_VERSION.BUNDLED_EXTENSIONS, + EXTENSION_VERSION.SIGNATURE_KEY_PAIR_ID, + EXTENSION_VERSION.PUBLISHED_WITH_ID + ); + latestQuery.addConditions(EXTENSION_VERSION.EXTENSION_ID.eq(EXTENSION.ID)); + var latest = latestQuery.asTable(); + + var query = dsl.selectQuery(); + query.addSelect( + NAMESPACE.ID, + NAMESPACE.NAME, + NAMESPACE.DISPLAY_NAME, + NAMESPACE.PUBLIC_ID, + EXTENSION.ID, + EXTENSION.NAME, + EXTENSION.PUBLIC_ID, + EXTENSION.AVERAGE_RATING, + EXTENSION.REVIEW_COUNT, + EXTENSION.DOWNLOAD_COUNT, + EXTENSION.PUBLISHED_DATE, + EXTENSION.LAST_UPDATED_DATE, + EXTENSION.ACTIVE, + EXTENSION.DEPRECATED, + EXTENSION.DOWNLOADABLE, + latest.field(EXTENSION_VERSION.ID), + latest.field(EXTENSION_VERSION.POTENTIALLY_MALICIOUS), + latest.field(EXTENSION_VERSION.VERSION), + latest.field(EXTENSION_VERSION.TARGET_PLATFORM), + latest.field(EXTENSION_VERSION.PREVIEW), + latest.field(EXTENSION_VERSION.PRE_RELEASE), + latest.field(EXTENSION_VERSION.TIMESTAMP), + latest.field(EXTENSION_VERSION.DISPLAY_NAME), + latest.field(EXTENSION_VERSION.DESCRIPTION), + latest.field(EXTENSION_VERSION.ENGINES), + latest.field(EXTENSION_VERSION.CATEGORIES), + latest.field(EXTENSION_VERSION.TAGS), + latest.field(EXTENSION_VERSION.EXTENSION_KIND), + latest.field(EXTENSION_VERSION.LICENSE), + latest.field(EXTENSION_VERSION.HOMEPAGE), + latest.field(EXTENSION_VERSION.REPOSITORY), + latest.field(EXTENSION_VERSION.SPONSOR_LINK), + latest.field(EXTENSION_VERSION.BUGS), + latest.field(EXTENSION_VERSION.MARKDOWN), + latest.field(EXTENSION_VERSION.GALLERY_COLOR), + latest.field(EXTENSION_VERSION.GALLERY_THEME), + latest.field(EXTENSION_VERSION.LOCALIZED_LANGUAGES), + latest.field(EXTENSION_VERSION.QNA), + latest.field(EXTENSION_VERSION.DEPENDENCIES), + latest.field(EXTENSION_VERSION.BUNDLED_EXTENSIONS), + SIGNATURE_KEY_PAIR.PUBLIC_ID, + USER_DATA.ID, + USER_DATA.ROLE, + USER_DATA.LOGIN_NAME, + USER_DATA.FULL_NAME, + USER_DATA.AVATAR_URL, + USER_DATA.PROVIDER_URL, + USER_DATA.PROVIDER + ); + query.addFrom(NAMESPACE); + query.addJoin(EXTENSION, EXTENSION.NAMESPACE_ID.eq(NAMESPACE.ID)); + query.addJoin(latest, JoinType.CROSS_APPLY, DSL.condition(true)); + query.addJoin(SIGNATURE_KEY_PAIR, JoinType.LEFT_OUTER_JOIN, SIGNATURE_KEY_PAIR.ID.eq(latest.field(EXTENSION_VERSION.SIGNATURE_KEY_PAIR_ID))); + query.addJoin(PERSONAL_ACCESS_TOKEN, JoinType.LEFT_OUTER_JOIN, PERSONAL_ACCESS_TOKEN.ID.eq(latest.field(EXTENSION_VERSION.PUBLISHED_WITH_ID))); + query.addJoin(USER_DATA, USER_DATA.ID.eq(PERSONAL_ACCESS_TOKEN.USER_DATA)); + query.addConditions( + PERSONAL_ACCESS_TOKEN.USER_DATA.eq(user.getId()), + NAMESPACE.NAME.equalIgnoreCase(namespace), + EXTENSION.NAME.equalIgnoreCase(extension) + ); + return query.fetchOne(row -> { + var extVersion = toExtensionVersionFull(row, null, new TableFieldMapper(latest)); + extVersion.getExtension().getNamespace().setDisplayName(row.get(NAMESPACE.DISPLAY_NAME)); + extVersion.getExtension().setActive(row.get(EXTENSION.ACTIVE)); + extVersion.getExtension().setDeprecated(row.get(EXTENSION.DEPRECATED)); + extVersion.getExtension().setDownloadable(row.get(EXTENSION.DOWNLOADABLE)); + return extVersion; + }); + } + public ExtensionVersion findLatestForAllUrls( Extension extension, String targetPlatform, @@ -1207,6 +1354,49 @@ public boolean hasSameVersion(ExtensionVersion extVersion) { ); } + public Integer count(String namespaceName, String extensionName) { + return dsl.select(DSL.count().as("count")) + .from(EXTENSION_VERSION) + .join(EXTENSION) + .on(EXTENSION.ID.eq(EXTENSION_VERSION.EXTENSION_ID)) + .join(NAMESPACE) + .on(NAMESPACE.ID.eq(EXTENSION.NAMESPACE_ID)) + .where(NAMESPACE.NAME.equalIgnoreCase(namespaceName)) + .and(EXTENSION.NAME.equalIgnoreCase(extensionName)) + .fetchOne("count", Integer.class); + } + + public boolean isDeleteAllVersions(String namespaceName, String extensionName, List targetVersions, UserData user) { + if(targetVersions.isEmpty()) { + return false; + } + + var all = dsl.select(DSL.count(EXTENSION_VERSION.ID).as("all")) + .from(EXTENSION_VERSION) + .join(EXTENSION).on(EXTENSION.ID.eq(EXTENSION_VERSION.EXTENSION_ID)) + .join(NAMESPACE).on(NAMESPACE.ID.eq(EXTENSION.NAMESPACE_ID)) + .and(NAMESPACE.NAME.equalIgnoreCase(namespaceName)) + .and(EXTENSION.NAME.equalIgnoreCase(extensionName)) + .fetchOne("all", Integer.class); + + var rows = targetVersions.stream().map((tv) -> DSL.row(tv.version(), tv.targetPlatform())).toArray(Row2[]::new); + var versions = DSL.values(rows).as("v", "version", "target"); + var VERSION = versions.field("version", String.class); + var TARGET = versions.field("target", String.class); + var actual = dsl.select(DSL.count(EXTENSION_VERSION.ID).as("actual")) + .from(versions) + .join(EXTENSION_VERSION).on(EXTENSION_VERSION.VERSION.eq(VERSION).and(EXTENSION_VERSION.TARGET_PLATFORM.eq(TARGET))) + .join(PERSONAL_ACCESS_TOKEN).on(PERSONAL_ACCESS_TOKEN.ID.eq(EXTENSION_VERSION.PUBLISHED_WITH_ID)) + .join(EXTENSION).on(EXTENSION.ID.eq(EXTENSION_VERSION.EXTENSION_ID)) + .join(NAMESPACE).on(NAMESPACE.ID.eq(EXTENSION.NAMESPACE_ID)) + .where(PERSONAL_ACCESS_TOKEN.USER_DATA.eq(user.getId())) + .and(NAMESPACE.NAME.equalIgnoreCase(namespaceName)) + .and(EXTENSION.NAME.equalIgnoreCase(extensionName)) + .fetchOne("actual", Integer.class); + + return Objects.equals(actual, all); + } + private interface FieldMapper { Field map(Field field); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java index 80038c20e..df07961f2 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionRepository.java @@ -29,6 +29,8 @@ public interface ExtensionVersionRepository extends Repository findByVersionAndExtensionNameIgnoreCaseAndExtensionNamespaceNameIgnoreCase(String version, String extensionName, String namespace); Streamable findByPublishedWithAndActive(PersonalAccessToken publishedWith, boolean active); @@ -48,8 +50,6 @@ public interface ExtensionVersionRepository extends Repository findVersions(Extension extension) { return extensionVersionRepo.findByExtension(extension); } @@ -438,8 +443,8 @@ public Streamable findTargetPlatformVersions(String version, S return extensionVersionRepo.findByVersionAndExtensionNameIgnoreCaseAndExtensionNamespaceNameIgnoreCase(version, extensionName, namespaceName); } - public int countVersions(Extension extension) { - return extensionVersionRepo.countByExtension(extension); + public int countVersions(String namespaceName, String extensionName) { + return extensionVersionJooqRepo.count(namespaceName, extensionName); } public Slice findNotMigratedItems(Pageable page) { @@ -523,6 +528,10 @@ public List findTargetPlatformsGroupedByVersion(Exte return extensionVersionJooqRepo.findTargetPlatformsGroupedByVersion(extension); } + public List findTargetPlatformsGroupedByVersion(Extension extension, UserData user) { + return extensionVersionJooqRepo.findTargetPlatformsGroupedByVersion(extension, user); + } + public List findVersionsForUrls(Extension extension, String targetPlatform, String version) { return extensionVersionJooqRepo.findVersionsForUrls(extension, targetPlatform, version); } @@ -563,6 +572,10 @@ public List findLatestVersions(UserData user) { return extensionVersionJooqRepo.findLatest(user); } + public ExtensionVersion findLatestVersion(UserData user, String namespace, String extension) { + return extensionVersionJooqRepo.findLatest(user, namespace, extension); + } + public List findExtensionTargetPlatforms(Extension extension) { return extensionVersionJooqRepo.findDistinctTargetPlatforms(extension); } @@ -638,4 +651,8 @@ public Streamable findDeprecatedExtensions(Extension replacement) { public List findRemoveFileResourceTypeResourceMigrationItems(int offset, int limit) { return migrationItemJooqRepo.findRemoveFileResourceTypeResourceMigrationItems(offset, limit); } + + public boolean isDeleteAllVersions(String namespaceName, String extensionName, List targetVersions, UserData user) { + return extensionVersionJooqRepo.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user); + } } \ No newline at end of file diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 89aec2767..2b883ea50 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -2429,7 +2429,6 @@ LocalRegistryService localRegistryService( StorageUtilService storageUtil, EclipseService eclipse, CacheService cache, - FileCacheDurationConfig fileCacheDurationConfig, ExtensionVersionIntegrityService integrityService ) { return new LocalRegistryService( @@ -2449,12 +2448,14 @@ LocalRegistryService localRegistryService( @Bean ExtensionService extensionService( + EntityManager entityManager, RepositoryService repositories, SearchUtilService search, CacheService cache, - PublishExtensionVersionHandler publishHandler + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler ) { - return new ExtensionService(repositories, search, cache, publishHandler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 70b400449..faecc2704 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -17,18 +17,23 @@ import org.eclipse.openvsx.cache.LatestExtensionVersionCacheKeyGenerator; import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.eclipse.TokenService; -import org.eclipse.openvsx.entities.Namespace; -import org.eclipse.openvsx.entities.NamespaceMembership; -import org.eclipse.openvsx.entities.PersonalAccessToken; -import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; +import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.eclipse.openvsx.storage.StorageUtilService; +import org.eclipse.openvsx.util.TargetPlatform; +import org.eclipse.openvsx.util.VersionService; +import org.jobrunr.scheduling.JobRequestScheduler; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -36,6 +41,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.data.util.Streamable; +import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @@ -48,6 +54,8 @@ import java.util.List; import java.util.function.Consumer; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; 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; @@ -59,7 +67,8 @@ @AutoConfigureWebClient @MockitoBean(types = { EclipseService.class, ClientRegistrationRepository.class, StorageUtilService.class, CacheService.class, - ExtensionValidator.class, SimpleMeterRegistry.class + ExtensionValidator.class, SimpleMeterRegistry.class, SearchUtilService.class, PublishExtensionVersionHandler.class, + JobRequestScheduler.class, VersionService.class, ExtensionVersionIntegrityService.class }) class UserAPITest { @@ -227,6 +236,29 @@ void testOwnNamespacesNotLoggedIn() throws Exception { .andExpect(status().isForbidden()); } + @Test + void testOwnExtension() throws Exception { + var userData = mockUserData(); + mockExtension(userData, 2, 0, 0); + mockMvc.perform(get("/user/extensions") + .with(user("test_user"))) + .andExpect(status().isOk()) + .andExpect(content().json(extensionJson(a -> { + var json = new ExtensionJson(); + json.setName("baz"); + json.setNamespace("foobar"); + a.add(json); + }))); + } + + @Test + void testOwnExtensionNotLoggedIn() throws Exception { + var userData = mockUserData(); + mockExtension(userData, 1, 0, 0); + mockMvc.perform(get("/user/extensions")) + .andExpect(status().isForbidden()); + } + @Test void testNamespaceMembers() throws Exception { mockNamespaceMemberships(NamespaceMembership.ROLE_OWNER); @@ -422,6 +454,97 @@ void testChangeNamespaceMemberSameRole() throws Exception { .andExpect(content().json(errorJson("User other_user already has the role contributor."))); } + @Test + void testDeleteExtensionNotLoggedIn() throws Exception { + mockExtension(null,2, 0, 0); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + @Test + void testDeleteExtensionNotPublisher() throws Exception { + var userData = mockUserData(); + + var otherUser = new UserData(); + otherUser.setLoginName("other_user"); + otherUser.setFullName("Other User"); + otherUser.setProviderUrl("http://example.com/test"); + Mockito.doReturn(otherUser).when(users).findLoggedInUser(); + + mockExtension(userData, 2, 0, 0); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) + .with(user("other_user")) + .with(csrf().asHeader())) + .andExpect(status().isNotFound()); + } + + @Test + void testDeleteExtension() throws Exception { + var userData = mockUserData(); + mockExtension(userData,2, 0, 0); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted foobar.baz"))); + } + + @Test + void testDeleteExtensionVersion() throws Exception { + var userData = mockUserData(); + mockExtension(userData,3, 0, 0); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted foobar.baz 1.0.0\nDeleted foobar.baz 2.0.0"))); + } + + @Test + void testDeleteLastExtensionVersion() throws Exception { + var userData = mockUserData(); + mockExtension(userData,1, 0, 0); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(content().json(successJson("Deleted foobar.baz"))); + } + + @Test + void testDeleteBundledExtension() throws Exception { + var userData = mockUserData(); + mockExtension(userData,2, 1, 0); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(errorJson("Extension foobar.baz is bundled by the following extension packs: foobar.bundle-1.0.0"))); + } + + @Test + void testDeleteDependingExtension() throws Exception { + var userData = mockUserData(); + mockExtension(userData,2, 0, 1); + mockMvc.perform(post("/user/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(errorJson("The following extensions have a dependency on foobar.baz: foobar.dependant-1.0.0"))); + } //---------- UTILITY ----------// @@ -504,6 +627,12 @@ private String namespacesJson(Consumer> content) throws Json return new ObjectMapper().writeValueAsString(json); } + private String extensionJson(Consumer> content) throws JsonProcessingException { + var json = new ArrayList(); + content.accept(json); + return new ObjectMapper().writeValueAsString(json); + } + private void mockNamespaceMemberships(String userRole) { var userData = mockUserData(); var namespace = new Namespace(); @@ -541,7 +670,94 @@ private String errorJson(String message) throws JsonProcessingException { var json = ResultJson.error(message); return new ObjectMapper().writeValueAsString(json); } - + + private Namespace mockNamespace() { + var namespace = new Namespace(); + namespace.setName("foobar"); + Mockito.when(repositories.findNamespace("foobar")) + .thenReturn(namespace); + Mockito.when(repositories.findActiveExtensions(namespace)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER)) + .thenReturn(false); + return namespace; + } + + private String createVersion(int major) { + return major + ".0.0"; + } + + private List mockExtension(UserData user, int numberOfVersions, int numberOfBundles, int numberOfDependants) { + var namespace = mockNamespace(); + var extension = new Extension(); + extension.setNamespace(namespace); + extension.setName("baz"); + extension.setActive(true); + Mockito.when(repositories.findExtension("baz", "foobar")) + .thenReturn(extension); + + var versions = new ArrayList(numberOfVersions); + for (var i = 0; i < numberOfVersions; i++) { + var extVersion = new ExtensionVersion(); + extVersion.setExtension(extension); + extVersion.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); + extVersion.setVersion(createVersion(i + 1)); + extVersion.setActive(true); + versions.add(extVersion); + Mockito.when(repositories.findFiles(extVersion)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findVersion(user, extVersion.getVersion(), TargetPlatform.NAME_UNIVERSAL, "baz", "foobar")) + .thenReturn(extVersion); + } + + extension.getVersions().addAll(versions); + Mockito.when(repositories.findVersions(extension)) + .thenReturn(Streamable.of(versions)); + Mockito.when(repositories.findLatestVersions(user)).thenReturn(List.of(versions.get(versions.size() - 1))); + Mockito.when(repositories.isDeleteAllVersions(eq("foobar"), eq("baz"), any(List.class), eq(user))).then(new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + return invocation.getArgument(2, List.class).size() == numberOfVersions; + } + }); + + var bundleExt = new Extension(); + bundleExt.setName("bundle"); + bundleExt.setNamespace(namespace); + + var bundles = new ArrayList(numberOfBundles); + for (var i = 0; i < numberOfBundles; i++) { + var bundle = new ExtensionVersion(); + bundle.setExtension(bundleExt); + bundle.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); + bundle.setVersion(createVersion(i + 1)); + bundles.add(bundle); + } + Mockito.when(repositories.findBundledExtensionsReference(extension)) + .thenReturn(Streamable.of(bundles)); + + var dependantExt = new Extension(); + dependantExt.setName("dependant"); + dependantExt.setNamespace(namespace); + + var dependants = new ArrayList(numberOfDependants); + for (var i = 0; i < numberOfDependants; i++) { + var dependant = new ExtensionVersion(); + dependant.setExtension(dependantExt); + dependant.setTargetPlatform(TargetPlatform.NAME_UNIVERSAL); + dependant.setVersion(createVersion(i + 1)); + dependants.add(dependant); + } + Mockito.when(repositories.findDependenciesReference(extension)) + .thenReturn(Streamable.of(dependants)); + + Mockito.when(repositories.findAllReviews(extension)) + .thenReturn(Streamable.empty()); + Mockito.when(repositories.findDeprecatedExtensions(extension)) + .thenReturn(Streamable.empty()); + return versions; + } + @TestConfiguration @Import(SecurityConfig.class) static class TestConfig { @@ -588,5 +804,46 @@ TokenService tokenService( LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKeyGenerator() { return new LatestExtensionVersionCacheKeyGenerator(); } + + @Bean + LocalRegistryService localRegistryService( + EntityManager entityManager, + RepositoryService repositories, + ExtensionService extensions, + VersionService versions, + UserService users, + SearchUtilService search, + ExtensionValidator validator, + StorageUtilService storageUtil, + EclipseService eclipse, + CacheService cache, + ExtensionVersionIntegrityService integrityService + ) { + return new LocalRegistryService( + entityManager, + repositories, + extensions, + versions, + users, + search, + validator, + storageUtil, + eclipse, + cache, + integrityService + ); + } + + @Bean + ExtensionService extensionService( + EntityManager entityManager, + RepositoryService repositories, + SearchUtilService search, + CacheService cache, + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler + ) { + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); + } } } \ No newline at end of file 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 95b118b9d..a7869f504 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -300,6 +300,8 @@ void testDeleteExtension() throws Exception { mockAdminUser(); mockExtension(2, 0, 0); mockMvc.perform(post("/admin/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) .with(csrf().asHeader())) .andExpect(status().isOk()) @@ -325,25 +327,25 @@ void testDeleteExtensionWithInvalidToken() throws Exception { @Test void testDeleteExtensionVersion() throws Exception { mockAdminUser(); - mockExtension(2, 0, 0); + mockExtension(3, 0, 0); mockMvc.perform(post("/admin/extension/{namespace}/{extension}/delete", "foobar", "baz") - .content("[{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") .contentType(MediaType.APPLICATION_JSON) .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) .with(csrf().asHeader())) .andExpect(status().isOk()) - .andExpect(content().json(successJson("Deleted foobar.baz 2.0.0"))); + .andExpect(content().json(successJson("Deleted foobar.baz 1.0.0\nDeleted foobar.baz 2.0.0"))); } @Test void testDeleteExtensionVersionWithToken() throws Exception { var token = mockAdminToken(); - mockExtension(2, 0, 0); + mockExtension(3, 0, 0); mockMvc.perform(post("/admin/api/extension/{namespace}/{extension}/delete?token={token}", "foobar", "baz", token.getValue()) - .content("[{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(content().json(successJson("Deleted foobar.baz 2.0.0"))); + .andExpect(content().json(successJson("Deleted foobar.baz 1.0.0\nDeleted foobar.baz 2.0.0"))); } @Test @@ -373,6 +375,8 @@ void testDeleteBundledExtension() throws Exception { mockAdminUser(); mockExtension(2, 1, 0); mockMvc.perform(post("/admin/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) .with(csrf().asHeader())) .andExpect(status().isBadRequest()) @@ -384,6 +388,8 @@ void testDeleteDependingExtension() throws Exception { mockAdminUser(); mockExtension(2, 0, 1); mockMvc.perform(post("/admin/extension/{namespace}/{extension}/delete", "foobar", "baz") + .content("[{\"targetPlatform\":\"universal\",\"version\":\"1.0.0\"},{\"targetPlatform\":\"universal\",\"version\":\"2.0.0\"}]") + .contentType(MediaType.APPLICATION_JSON) .with(user("admin_user").authorities(new SimpleGrantedAuthority(("ROLE_ADMIN")))) .with(csrf().asHeader())) .andExpect(status().isBadRequest()) @@ -1239,7 +1245,7 @@ private List mockExtension(int numberOfVersions, int numberOfB } extension.getVersions().addAll(versions); - Mockito.when(repositories.countVersions(extension)).thenReturn(numberOfVersions); + Mockito.when(repositories.countVersions(namespace.getName(), extension.getName())).thenReturn(numberOfVersions); Mockito.when(repositories.findLatestVersion(namespace.getName(), extension.getName(), null, false, false)) .thenReturn(versions.get(numberOfVersions - 1)); Mockito.when(repositories.findVersions(extension)) @@ -1285,7 +1291,7 @@ private List mockExtension(int numberOfVersions, int numberOfB } private String createVersion(int major) { - return Integer.toString(major) + ".0.0"; + return major + ".0.0"; } private String adminStatisticsJson(Consumer content) throws JsonProcessingException { @@ -1419,12 +1425,14 @@ LocalRegistryService localRegistryService( @Bean ExtensionService extensionService( + EntityManager entityManager, RepositoryService repositories, SearchUtilService search, CacheService cache, - PublishExtensionVersionHandler publishHandler + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler ) { - return new ExtensionService(repositories, search, cache, publishHandler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java index 7777b2dce..35ef5e9ff 100644 --- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java @@ -26,6 +26,7 @@ import org.eclipse.openvsx.storage.*; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.TargetPlatform; +import org.jobrunr.scheduling.JobRequestScheduler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,7 +57,8 @@ @MockitoBean(types = { EntityManager.class, SearchUtilService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, AzureDownloadCountService.class, CacheService.class, - UserService.class, PublishExtensionVersionHandler.class, SimpleMeterRegistry.class, FileCacheDurationConfig.class + UserService.class, PublishExtensionVersionHandler.class, SimpleMeterRegistry.class, FileCacheDurationConfig.class, + JobRequestScheduler.class }) class EclipseServiceTest { @@ -329,12 +331,14 @@ EclipseService eclipseService( @Bean ExtensionService extensionService( + EntityManager entityManager, RepositoryService repositories, SearchUtilService search, CacheService cache, - PublishExtensionVersionHandler publishHandler + PublishExtensionVersionHandler publishHandler, + JobRequestScheduler scheduler ) { - return new ExtensionService(repositories, search, cache, publishHandler); + return new ExtensionService(entityManager, repositories, search, cache, publishHandler, scheduler); } @Bean 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 5c5bdfce0..ab4dfc322 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -133,7 +133,7 @@ void testExecuteQueries() { () -> repositories.findExtensions(LONG_LIST), () -> repositories.findExtensions(userData), () -> repositories.findFilesByType(List.of(extVersion), STRING_LIST), - () -> repositories.countVersions(extension), + () -> repositories.countVersions("namespaceName", "extensionName"), () -> repositories.topMostDownloadedExtensions(1), () -> repositories.countActiveAccessTokens(userData), () -> repositories.topMostActivePublishingUsers(1), @@ -215,6 +215,10 @@ void testExecuteQueries() { () -> repositories.findLatestReplacement(1L, null, false, false), () -> repositories.findNotMigratedItems(page), () -> repositories.findRemoveFileResourceTypeResourceMigrationItems(0, 1), + () -> repositories.findTargetPlatformsGroupedByVersion(extension, userData), + () -> repositories.findVersion(userData,"version", "targetPlatform", "extensionName", "namespace"), + () -> repositories.findLatestVersion(userData, "namespaceName", "extensionName"), + () -> repositories.isDeleteAllVersions("namespaceName", "extensionName", Collections.emptyList(), userData), () -> repositories.deactivateAccessTokens(userData) ); diff --git a/webui/package.json b/webui/package.json index c68485838..b667d2369 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "openvsx-webui", - "version": "0.16.4", + "version": "0.17.0", "description": "User interface for Eclipse Open VSX", "keywords": [ "react", diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 8ee25ede1..e5ea6fb52 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -418,6 +418,43 @@ export class ExtensionRegistryService { }); } + async getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { + const csrfResponse = await this.getCsrfToken(abortController); + const headers: Record = {}; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'GET', + credentials: true, + headers: headers, + endpoint: createAbsoluteURL([this.serverUrl, 'user', 'extension', namespace, extension]) + }); + } + + async deleteExtensions(abortController: AbortController, req: { namespace: string, extension: string, targetPlatformVersions?: object[] }): Promise> { + const csrfResponse = await this.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'POST', + credentials: true, + endpoint: createAbsoluteURL([this.serverUrl, 'user', 'extension', req.namespace, req.extension, 'delete']), + headers, + payload: req.targetPlatformVersions + }); + } + async getRegistryVersion(abortController: AbortController): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'api', 'version']); return sendRequest({ abortController, endpoint }); diff --git a/webui/src/other-pages.tsx b/webui/src/other-pages.tsx index 9b229e25b..b908c4a2b 100644 --- a/webui/src/other-pages.tsx +++ b/webui/src/other-pages.tsx @@ -108,6 +108,7 @@ export const OtherPages: FunctionComponent = (props) => { } /> } /> + } /> } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/extension-admin.tsx b/webui/src/pages/admin-dashboard/extension-admin.tsx index 79f766215..e6dced536 100644 --- a/webui/src/pages/admin-dashboard/extension-admin.tsx +++ b/webui/src/pages/admin-dashboard/extension-admin.tsx @@ -13,7 +13,7 @@ import { SearchListContainer } from './search-list-container'; import { ExtensionListSearchfield } from '../extension-list/extension-list-searchfield'; import { Button, Typography } from '@mui/material'; import { MainContext } from '../../context'; -import { isError, Extension } from '../../extension-registry-types'; +import { isError, Extension, TargetPlatformVersion } from '../../extension-registry-types'; import { ExtensionVersionContainer } from './extension-version-container'; import { StyledInput } from './namespace-input'; @@ -77,6 +77,15 @@ export const ExtensionAdmin: FunctionComponent = props => { } }; + const onRemove = async (targetPlatformVersions?: TargetPlatformVersion[]) => { + if (extension == null) { + return; + } + + await service.admin.deleteExtensions(abortController.current, { namespace: extension.namespace, extension: extension.name, targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ version, targetPlatform })) }); + await findExtension(); + }; + return { ]} listContainer={ extension ? - + : '' } loading={loading} diff --git a/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx b/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx index e01d30cbc..77860fae8 100644 --- a/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx +++ b/webui/src/pages/admin-dashboard/extension-remove-dialog.tsx @@ -16,7 +16,7 @@ import { MainContext } from '../../context'; import { getTargetPlatformDisplayName } from '../../utils'; export const ExtensionRemoveDialog: FunctionComponent = props => { - const { service, handleError } = useContext(MainContext); + const { handleError } = useContext(MainContext); const abortController = useRef(new AbortController()); useEffect(() => { @@ -28,11 +28,6 @@ export const ExtensionRemoveDialog: FunctionComponent { - return props.targetPlatformVersions.find(targetPlatformVersion => targetPlatformVersion.targetPlatform === WILDCARD && targetPlatformVersion.version === WILDCARD); - }; - const removeVersions = () => { return props.targetPlatformVersions.length > 1; }; @@ -40,18 +35,7 @@ export const ExtensionRemoveDialog: FunctionComponent { try { setWorking(true); - let targetPlatformVersions = undefined; - if (!removeAll()) { - targetPlatformVersions = props.targetPlatformVersions - .filter(t => t.targetPlatform !== WILDCARD && t.version !== WILDCARD) - .map(t => { - return { targetPlatform: t.targetPlatform, version: t.version }; - }); - } - - await service.admin.deleteExtensions(abortController.current, { namespace: props.extension.namespace, extension: props.extension.name, targetPlatformVersions: targetPlatformVersions }); - - props.onUpdate(); + await props.onRemove(props.targetPlatformVersions); setDialogOpen(false); } catch (err) { handleError(err); @@ -60,13 +44,7 @@ export const ExtensionRemoveDialog: FunctionComponent