From 6c0bba5d36175264b6440726914673a2b419a563 Mon Sep 17 00:00:00 2001 From: amvanbaren Date: Thu, 12 Sep 2024 17:27:43 +0300 Subject: [PATCH] Feature: extension control - Add configuration property to run extension control update on server start - Add `deprecated`, `replacement` and `downloadable` fields to ExtensionJson response - Change appearance of deprecated extension in webui - Remove deleted extension as replacement extension for deprecated extension - Show deprecated extensions in /user/extensions - Show deprecated in admin dashboard --- server/src/dev/resources/application.yml | 2 + .../eclipse/openvsx/LocalRegistryService.java | 67 +++++++-- .../openvsx/adapter/LocalVSCodeService.java | 12 +- .../org/eclipse/openvsx/admin/AdminAPI.java | 3 +- .../eclipse/openvsx/admin/AdminService.java | 6 + .../eclipse/openvsx/cache/CacheService.java | 2 +- .../eclipse/openvsx/entities/Extension.java | 41 ++++- .../openvsx/entities/ExtensionVersion.java | 4 + .../ExtensionControlJobRequestHandler.java | 96 ++++++++++++ .../ExtensionControlService.java | 140 ++++++++++++++++++ .../eclipse/openvsx/json/ExtensionJson.java | 16 +- .../json/ExtensionReplacementJson.java | 29 ++++ .../eclipse/openvsx/json/SearchEntryJson.java | 3 + .../MirrorExtensionHandlerInterceptor.java | 8 +- .../PublishExtensionVersionHandler.java | 30 +++- .../repositories/ExtensionJooqRepository.java | 15 +- .../repositories/ExtensionRepository.java | 2 + .../ExtensionVersionJooqRepository.java | 80 +++++++++- .../repositories/RepositoryService.java | 15 +- .../openvsx/search/RelevanceService.java | 7 + .../org/eclipse/openvsx/util/ExtensionId.java | 12 ++ .../org/eclipse/openvsx/util/NamingUtil.java | 8 + .../org/eclipse/openvsx/jooq/Keys.java | 1 + .../openvsx/jooq/tables/Extension.java | 42 +++++- .../jooq/tables/records/ExtensionRecord.java | 131 ++++++++++++++-- .../migration/V1_47__Deprecated_Extension.sql | 12 ++ server/src/main/resources/ehcache.xml | 10 ++ .../org/eclipse/openvsx/RegistryAPITest.java | 11 +- .../eclipse/openvsx/admin/AdminAPITest.java | 2 + .../RepositoryServiceSmokeTest.java | 9 +- webui/package.json | 2 +- webui/src/extension-registry-types.ts | 8 + .../extension-version-container.tsx | 13 +- .../extension-detail-overview.tsx | 6 +- .../extension-detail/extension-detail.tsx | 15 +- .../extension-list/extension-list-item.tsx | 4 +- .../user-namespace-extension-list-item.tsx | 13 +- 37 files changed, 806 insertions(+), 71 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java create mode 100644 server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/ExtensionReplacementJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/util/ExtensionId.java create mode 100644 server/src/main/resources/db/migration/V1_47__Deprecated_Extension.sql diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index 1ae0d0e9d..babccf8f8 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -143,6 +143,8 @@ ovsx: base-url: https://api.eclipse.org publisher-agreement: timezone: US/Eastern + extension-control: + update-on-start: true integrity: key-pair: create # create, renew, delete, 'undefined' registry: diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 99c55138f..f78027bf0 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -92,6 +92,9 @@ public LocalRegistryService( this.observations = observations; } + @Value("${ovsx.webui.url:}") + String webuiUrl; + @Value("${ovsx.registry.version:}") String registryVersion; @@ -269,15 +272,16 @@ public SearchResultJson search(ISearchService.Options options) { @Override public QueryResultJson query(QueryRequest request) { if (!StringUtils.isEmpty(request.extensionId)) { - var split = request.extensionId.split("\\."); - if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) + var extensionId = NamingUtil.fromExtensionId(request.extensionId); + if(extensionId == null) throw new ErrorResultException("The 'extensionId' parameter must have the format 'namespace.extension'."); - if (!StringUtils.isEmpty(request.namespaceName) && !request.namespaceName.equals(split[0])) + if (!StringUtils.isEmpty(request.namespaceName) && !request.namespaceName.equals(extensionId.namespace())) throw new ErrorResultException("Conflicting parameters 'extensionId' and 'namespaceName'"); - if (!StringUtils.isEmpty(request.extensionName) && !request.extensionName.equals(split[1])) + if (!StringUtils.isEmpty(request.extensionName) && !request.extensionName.equals(extensionId.extension())) throw new ErrorResultException("Conflicting parameters 'extensionId' and 'extensionName'"); - request.namespaceName = split[0]; - request.extensionName = split[1]; + + request.namespaceName = extensionId.namespace(); + request.extensionName = extensionId.extension(); request.extensionId = null; } @@ -320,15 +324,16 @@ public QueryResultJson query(QueryRequest request) { @Override public QueryResultJson queryV2(QueryRequestV2 request) { if (!StringUtils.isEmpty(request.extensionId)) { - var split = request.extensionId.split("\\."); - if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) + var extensionId = NamingUtil.fromExtensionId(request.extensionId); + if (extensionId == null) throw new ErrorResultException("The 'extensionId' parameter must have the format 'namespace.extension'."); - if (!StringUtils.isEmpty(request.namespaceName) && !request.namespaceName.equals(split[0])) + if (!StringUtils.isEmpty(request.namespaceName) && !request.namespaceName.equals(extensionId.namespace())) throw new ErrorResultException("Conflicting parameters 'extensionId' and 'namespaceName'"); - if (!StringUtils.isEmpty(request.extensionName) && !request.extensionName.equals(split[1])) + if (!StringUtils.isEmpty(request.extensionName) && !request.extensionName.equals(extensionId.extension())) throw new ErrorResultException("Conflicting parameters 'extensionId' and 'extensionName'"); - request.namespaceName = split[0]; - request.extensionName = split[1]; + + request.namespaceName = extensionId.namespace(); + request.extensionName = extensionId.extension(); request.extensionId = null; } @@ -785,6 +790,18 @@ public ExtensionJson toExtensionVersionJson(ExtensionVersion extVersion, String var latestPreRelease = repositories.findLatestVersionForAllUrls(extension, targetPlatform, true, onlyActive); var json = extVersion.toExtensionJson(); + if(extension.getReplacement() != null) { + var replacementId = extension.getReplacement().getId(); + var replacement = repositories.findLatestReplacement(replacementId, targetPlatform, false, onlyActive); + if(replacement != null) { + json.replacement = new ExtensionReplacementJson(); + json.replacement.url = UrlUtil.createApiUrl(webuiUrl, "extension", replacement.getExtension().getNamespace().getName(), replacement.getExtension().getName()); + json.replacement.displayName = StringUtils.isNotEmpty(replacement.getDisplayName()) + ? replacement.getDisplayName() + : replacement.getExtension().getName(); + } + } + json.preview = latest != null && latest.isPreview(); json.versionAlias = new ArrayList<>(2); if (latest != null && extVersion.getVersion().equals(latest.getVersion())) @@ -853,6 +870,19 @@ public ExtensionJson toExtensionVersionJson( json.namespaceUrl = createApiUrl(serverUrl, "api", json.namespace); json.reviewsUrl = createApiUrl(serverUrl, "api", json.namespace, json.name, "reviews"); + var extension = extVersion.getExtension(); + if(extension.getReplacement() != null) { + var replacementId = extension.getReplacement().getId(); + var replacement = repositories.findLatestReplacement(replacementId, targetPlatformParam, false, true); + if(replacement != null) { + json.replacement = new ExtensionReplacementJson(); + json.replacement.url = UrlUtil.createApiUrl(serverUrl, "api", replacement.getExtension().getNamespace().getName(), replacement.getExtension().getName()); + json.replacement.displayName = StringUtils.isNotEmpty(replacement.getDisplayName()) + ? replacement.getDisplayName() + : replacement.getExtension().getName(); + } + } + json.versionAlias = new ArrayList<>(2); if (extVersion.equals(latest)) { json.versionAlias.add(VersionAlias.LATEST); @@ -926,6 +956,19 @@ public ExtensionJson toExtensionVersionJsonV2( json.reviewsUrl = createApiUrl(serverUrl, "api", json.namespace, json.name, "reviews"); json.url = createApiVersionUrl(serverUrl, json); + var extension = extVersion.getExtension(); + if(extension.getReplacement() != null) { + var replacementId = extension.getReplacement().getId(); + var replacement = repositories.findLatestReplacement(replacementId, targetPlatformParam, false, true); + if(replacement != null) { + json.replacement = new ExtensionReplacementJson(); + json.replacement.url = UrlUtil.createApiUrl(serverUrl, "api", replacement.getExtension().getNamespace().getName(), replacement.getExtension().getName()); + json.replacement.displayName = StringUtils.isNotEmpty(replacement.getDisplayName()) + ? replacement.getDisplayName() + : replacement.getExtension().getName(); + } + } + json.versionAlias = new ArrayList<>(2); if (extVersion.equals(latest)) { json.versionAlias.add(VersionAlias.LATEST); diff --git a/server/src/main/java/org/eclipse/openvsx/adapter/LocalVSCodeService.java b/server/src/main/java/org/eclipse/openvsx/adapter/LocalVSCodeService.java index 5433a160b..8356c4cf1 100644 --- a/server/src/main/java/org/eclipse/openvsx/adapter/LocalVSCodeService.java +++ b/server/src/main/java/org/eclipse/openvsx/adapter/LocalVSCodeService.java @@ -114,14 +114,10 @@ public ExtensionQueryResult extensionQuery(ExtensionQueryParam param, int defaul extensionsList = repositories.findActiveExtensionsByPublicId(extensionIds, BuiltInExtensionUtil.getBuiltInNamespace()); } else if (!extensionNames.isEmpty()) { extensionsList = extensionNames.stream() - .map(name -> name.split("\\.")) - .filter(split -> split.length == 2) - .filter(split -> !BuiltInExtensionUtil.isBuiltIn(split[0])) - .map(split -> { - var name = split[1]; - var namespaceName = split[0]; - return repositories.findActiveExtension(name, namespaceName); - }) + .map(NamingUtil::fromExtensionId) + .filter(Objects::nonNull) + .filter(extensionId -> !BuiltInExtensionUtil.isBuiltIn(extensionId.namespace())) + .map(extensionId -> repositories.findActiveExtension(extensionId.extension(), extensionId.namespace())) .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (!search.isEnabled()) { 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 685372e29..ad85ff142 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -34,8 +34,6 @@ import java.util.Objects; import java.util.stream.Collectors; -import static org.eclipse.openvsx.entities.UserData.ROLE_ADMIN; - @RestController public class AdminAPI { @@ -197,6 +195,7 @@ public ResponseEntity getExtension(@PathVariable String namespace json.name = extension.getName(); json.allVersions = Collections.emptyMap(); json.allTargetPlatformVersions = Collections.emptyList(); + json.deprecated = extension.isDeprecated(); json.active = extension.isActive(); } return ResponseEntity.ok(json); 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 dd39f7dd3..5ec9845e4 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -131,6 +131,12 @@ protected ResultJson deleteExtension(Extension extension, UserData admin) throws entityManager.remove(review); } + var deprecatedExtensions = repositories.findDeprecatedExtensions(extension); + for(var deprecatedExtension : deprecatedExtensions) { + deprecatedExtension.setReplacement(null); + cache.evictExtensionJsons(deprecatedExtension); + } + entityManager.remove(extension); search.removeSearchEntry(extension); diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java index c22b540b1..3a84d6f0a 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java @@ -9,7 +9,6 @@ * ****************************************************************************** */ package org.eclipse.openvsx.cache; -import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionVersion; @@ -32,6 +31,7 @@ public class CacheService { public static final String CACHE_NAMESPACE_DETAILS_JSON = "namespace.details.json"; public static final String CACHE_AVERAGE_REVIEW_RATING = "average.review.rating"; public static final String CACHE_SITEMAP = "sitemap"; + public static final String CACHE_MALICIOUS_EXTENSIONS = "malicious.extensions"; public static final String GENERATOR_EXTENSION_JSON = "extensionJsonCacheKeyGenerator"; public static final String GENERATOR_LATEST_EXTENSION_VERSION = "latestExtensionVersionCacheKeyGenerator"; diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Extension.java b/server/src/main/java/org/eclipse/openvsx/entities/Extension.java index 06bbed4ae..d1a88e6b4 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Extension.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Extension.java @@ -55,6 +55,13 @@ public class Extension implements Serializable { LocalDateTime lastUpdatedDate; + boolean deprecated; + + @OneToOne + Extension replacement; + + boolean downloadable; + /** * Convert to a search entity for Elasticsearch. */ @@ -163,6 +170,30 @@ public List getVersions() { return versions; } + public boolean isDeprecated() { + return deprecated; + } + + public void setDeprecated(boolean deprecated) { + this.deprecated = deprecated; + } + + public Extension getReplacement() { + return replacement; + } + + public void setReplacement(Extension replacement) { + this.replacement = replacement; + } + + public boolean isDownloadable() { + return downloadable; + } + + public void setDownloadable(boolean downloadable) { + this.downloadable = downloadable; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -178,11 +209,17 @@ public boolean equals(Object o) { && Objects.equals(averageRating, extension.averageRating) && Objects.equals(reviewCount, extension.reviewCount) && Objects.equals(publishedDate, extension.publishedDate) - && Objects.equals(lastUpdatedDate, extension.lastUpdatedDate); + && Objects.equals(lastUpdatedDate, extension.lastUpdatedDate) + && Objects.equals(deprecated, extension.deprecated) + && Objects.equals(replacement, extension.replacement) + && Objects.equals(downloadable, extension.downloadable); } @Override public int hashCode() { - return Objects.hash(id, publicId, name, namespace, versions, active, averageRating, reviewCount, downloadCount, publishedDate, lastUpdatedDate); + return Objects.hash( + id, publicId, name, namespace, versions, active, averageRating, reviewCount, downloadCount, + publishedDate, lastUpdatedDate, deprecated, replacement, downloadable + ); } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java index 69da4f162..8160ff52d 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/ExtensionVersion.java @@ -181,6 +181,9 @@ public ExtensionJson toExtensionJson() { if (this.getBundledExtensions() != null) { json.bundledExtensions = toExtensionReferenceJson(this.getBundledExtensions()); } + + json.deprecated = extension.isDeprecated(); + json.downloadable = extension.isDownloadable(); return json; } @@ -213,6 +216,7 @@ public SearchEntryJson toSearchEntryJson() { entry.timestamp = TimeUtil.toUTCString(this.getTimestamp()); entry.displayName = this.getDisplayName(); entry.description = this.getDescription(); + entry.deprecated = extension.isDeprecated(); return entry; } diff --git a/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java new file mode 100644 index 000000000..b8eef8c9f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlJobRequestHandler.java @@ -0,0 +1,96 @@ +/** ****************************************************************************** + * Copyright (c) 2024 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.extension_control; + +import com.fasterxml.jackson.databind.JsonNode; +import org.eclipse.openvsx.admin.AdminService; +import org.eclipse.openvsx.migration.HandlerJobRequest; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NamingUtil; +import org.jobrunr.jobs.lambdas.JobRequestHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class ExtensionControlJobRequestHandler implements JobRequestHandler> { + + protected final Logger logger = LoggerFactory.getLogger(ExtensionControlJobRequestHandler.class); + + private final AdminService admin; + private final ExtensionControlService service; + private final RepositoryService repositories; + + public ExtensionControlJobRequestHandler(AdminService admin, ExtensionControlService service, RepositoryService repositories) { + this.admin = admin; + this.service = service; + this.repositories = repositories; + } + + @Override + public void run(HandlerJobRequest jobRequest) throws Exception { + logger.info("Run extension control job"); + var json = service.getExtensionControlJson(); + logger.info("Got extension control JSON"); + processMaliciousExtensions(json); + processDeprecatedExtensions(json); + } + + private void processMaliciousExtensions(JsonNode json) { + logger.info("Process malicious extensions"); + var node = json.get("malicious"); + if(!node.isArray()) { + logger.error("field 'malicious' is not an array"); + return; + } + + var adminUser = service.createExtensionControlUser(); + for(var item : node) { + logger.info("malicious: {}", item.asText()); + var extensionId = NamingUtil.fromExtensionId(item.asText()); + if(extensionId != null && repositories.hasExtension(extensionId.namespace(), extensionId.extension())) { + logger.info("delete malicious extension"); + admin.deleteExtension(extensionId.namespace(), extensionId.extension(), adminUser); + } + } + } + + private void processDeprecatedExtensions(JsonNode json) { + logger.info("Process deprecated extensions"); + var node = json.get("deprecated"); + if(!node.isObject()) { + logger.error("field 'deprecated' is not an object"); + return; + } + + node.fields().forEachRemaining(field -> { + logger.info("deprecated: {}", field.getKey()); + var extensionId = NamingUtil.fromExtensionId(field.getKey()); + if(extensionId == null) { + return; + } + + var value = field.getValue(); + if(value.isBoolean()) { + service.updateExtension(extensionId, value.asBoolean(), null, true); + } else if(value.isObject()) { + var replacement = value.get("extension"); + var replacementId = replacement != null && replacement.isObject() + ? NamingUtil.fromExtensionId(replacement.get("id").asText()) + : null; + + var disallowInstall = value.has("disallowInstall") && value.get("disallowInstall").asBoolean(false); + service.updateExtension(extensionId, true, replacementId, !disallowInstall); + } else { + logger.error("field '{}' is not an object or a boolean", extensionId); + } + }); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java new file mode 100644 index 000000000..0d3ebfa31 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/extension_control/ExtensionControlService.java @@ -0,0 +1,140 @@ +/** ****************************************************************************** + * Copyright (c) 2024 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.extension_control; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.eclipse.openvsx.cache.CacheService; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.migration.HandlerJobRequest; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.util.ExtensionId; +import org.eclipse.openvsx.util.TimeUtil; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.jobrunr.scheduling.cron.Cron; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URL; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.eclipse.openvsx.cache.CacheService.CACHE_MALICIOUS_EXTENSIONS; + +@Component +public class ExtensionControlService { + + protected final Logger logger = LoggerFactory.getLogger(ExtensionControlService.class); + + private final JobRequestScheduler scheduler; + private final RepositoryService repositories; + private final EntityManager entityManager; + private final SearchUtilService search; + private final CacheService cache; + + @Value("${ovsx.data.mirror.enabled:false}") + boolean mirrorEnabled; + + @Value("${ovsx.extension-control.update-on-start:false}") + boolean updateOnStart; + + @Value("${ovsx.migrations.delay.seconds:0}") + long delay; + + public ExtensionControlService( + JobRequestScheduler scheduler, + RepositoryService repositories, + EntityManager entityManager, + SearchUtilService search, + CacheService cache + ) { + this.scheduler = scheduler; + this.repositories = repositories; + this.entityManager = entityManager; + this.search = search; + this.cache = cache; + } + + @EventListener + public void applicationStarted(ApplicationStartedEvent event) { + if(mirrorEnabled) { + return; + } + if(updateOnStart) { + scheduler.schedule(TimeUtil.getCurrentUTC().plusSeconds(delay), new HandlerJobRequest<>(ExtensionControlJobRequestHandler.class)); + } + + scheduler.scheduleRecurrently("UpdateExtensionControl", Cron.daily(1, 8), ZoneId.of("UTC"), new HandlerJobRequest<>(ExtensionControlJobRequestHandler.class)); + } + + @Transactional + public UserData createExtensionControlUser() { + var userName = "ExtensionControlUser"; + var user = repositories.findUserByLoginName(null, userName); + if(user == null) { + user = new UserData(); + user.setLoginName(userName); + entityManager.persist(user); + } + return user; + } + + @Transactional + public void updateExtension(ExtensionId extensionId, boolean deprecated, ExtensionId replacementId, boolean downloadable) { + var extension = repositories.findExtension(extensionId.extension(), extensionId.namespace()); + if(extension == null) { + return; + } + + var wasDeprecated = extension.isDeprecated(); + extension.setDeprecated(deprecated); + extension.setDownloadable(downloadable); + if(replacementId != null) { + var replacement = repositories.findExtension(replacementId.extension(), replacementId.namespace()); + extension.setReplacement(replacement); + } + if(deprecated != wasDeprecated) { + cache.evictNamespaceDetails(extension); + cache.evictLatestExtensionVersion(extension); + cache.evictExtensionJsons(extension); + search.updateSearchEntry(extension); + } + } + + public JsonNode getExtensionControlJson() throws IOException { + var url = new URL("https://github.com/open-vsx/publish-extensions/raw/master/extension-control/extensions.json"); + return new ObjectMapper().readValue(url, JsonNode.class); + } + + @Cacheable(CACHE_MALICIOUS_EXTENSIONS) + public List getMaliciousExtensionIds() throws IOException { + var json = getExtensionControlJson(); + var malicious = json.get("malicious"); + if(!malicious.isArray()) { + logger.error("field 'malicious' is not an array"); + return Collections.emptyList(); + } + + var list = new ArrayList(); + malicious.forEach(node -> list.add(node.asText())); + return list; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java index 28931973d..ba4abb6db 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java @@ -183,6 +183,15 @@ public static ExtensionJson error(String message) { @Schema(description = "version metadata URL") public String url; + @Schema(description = "Indicates whether the extension is deprecated") + public boolean deprecated; + + @Schema(description = "Reference to extension that replaces this extension when it's deprecated") + public ExtensionReplacementJson replacement; + + @Schema(description = "Whether to show downloads in user interfaces") + public boolean downloadable; + @Override public boolean equals(Object o) { if (this == o) return true; @@ -228,7 +237,10 @@ public boolean equals(Object o) { && Objects.equals(bundledExtensions, that.bundledExtensions) && Objects.equals(downloads, that.downloads) && Objects.equals(allTargetPlatformVersions, that.allTargetPlatformVersions) - && Objects.equals(url, that.url); + && Objects.equals(url, that.url) + && Objects.equals(deprecated, that.deprecated) + && Objects.equals(replacement, that.replacement) + && Objects.equals(downloadable, that.downloadable); } @Override @@ -238,7 +250,7 @@ public int hashCode() { active, verified, unrelatedPublisher, namespaceAccess, allVersions, allVersionsUrl, averageRating, downloadCount, reviewCount, versionAlias, timestamp, preview, displayName, description, engines, categories, extensionKind, tags, license, homepage, repository, bugs, markdown, galleryColor, galleryTheme, qna, badges, - dependencies, bundledExtensions, downloads, allTargetPlatformVersions, url + dependencies, bundledExtensions, downloads, allTargetPlatformVersions, url, deprecated, replacement, downloadable ); } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/json/ExtensionReplacementJson.java b/server/src/main/java/org/eclipse/openvsx/json/ExtensionReplacementJson.java new file mode 100644 index 000000000..606bf16df --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/ExtensionReplacementJson.java @@ -0,0 +1,29 @@ +/** ****************************************************************************** + * Copyright (c) 2024 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.io.Serializable; + +@Schema( + name = "ExtensionReplacement", + description = "Metadata of an extension replacement" +) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ExtensionReplacementJson implements Serializable { + + @Schema(description = "URL of the extension replacement") + public String url; + + @Schema(description = "Name to be displayed in user interfaces") + public String displayName; +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/SearchEntryJson.java b/server/src/main/java/org/eclipse/openvsx/json/SearchEntryJson.java index f6b056a02..b18d26bcf 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/SearchEntryJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/SearchEntryJson.java @@ -81,4 +81,7 @@ public class SearchEntryJson implements Serializable { public String displayName; public String description; + + @Schema(description = "Indicates whether the extension is deprecated") + public boolean deprecated; } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/mirror/MirrorExtensionHandlerInterceptor.java b/server/src/main/java/org/eclipse/openvsx/mirror/MirrorExtensionHandlerInterceptor.java index 31b4ece6d..3ae6ab863 100644 --- a/server/src/main/java/org/eclipse/openvsx/mirror/MirrorExtensionHandlerInterceptor.java +++ b/server/src/main/java/org/eclipse/openvsx/mirror/MirrorExtensionHandlerInterceptor.java @@ -11,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.util.NamingUtil; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -46,10 +47,9 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } private Map extractQueryParams(HttpServletRequest request) { - var itemName = request.getParameter("itemName"); - var itemNamePieces = itemName.split("\\."); - return itemNamePieces.length == 2 - ? Map.of("namespaceName", itemNamePieces[0], "extensionName", itemNamePieces[1]) + var itemName = NamingUtil.fromExtensionId(request.getParameter("itemName")); + return itemName != null + ? Map.of("namespaceName", itemName.namespace(), "extensionName", itemName.extension()) : Collections.emptyMap(); } diff --git a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java index b66f43852..28536c7f9 100644 --- a/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java @@ -21,8 +21,10 @@ import org.eclipse.openvsx.UserService; import org.eclipse.openvsx.adapter.VSCodeIdNewExtensionJobRequest; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.extension_control.ExtensionControlService; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.util.NamingUtil; import org.eclipse.openvsx.util.TempFile; import org.jobrunr.scheduling.JobRequestScheduler; @@ -49,6 +51,7 @@ public class PublishExtensionVersionHandler { private final JobRequestScheduler scheduler; private final UserService users; private final ExtensionValidator validator; + private final ExtensionControlService extensionControl; private final ObservationRegistry observations; public PublishExtensionVersionHandler( @@ -59,6 +62,7 @@ public PublishExtensionVersionHandler( JobRequestScheduler scheduler, UserService users, ExtensionValidator validator, + ExtensionControlService extensionControl, ObservationRegistry observations ) { this.service = service; @@ -68,6 +72,7 @@ public PublishExtensionVersionHandler( this.scheduler = scheduler; this.users = users; this.validator = validator; + this.extensionControl = extensionControl; this.observations = observations; } @@ -116,6 +121,9 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us if (nameIssue.isPresent()) { throw new ErrorResultException(nameIssue.get().toString()); } + if(isMalicious(namespaceName, extensionName)) { + throw new ErrorResultException(NamingUtil.toExtensionId(namespaceName, extensionName) + " is a known malicious extension"); + } var version = processor.getVersion(); var versionIssue = validator.validateExtensionVersion(version); @@ -138,6 +146,8 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us extension.setName(extensionName); extension.setNamespace(namespace); extension.setPublishedDate(extVersion.getTimestamp()); + extension.setDeprecated(false); + extension.setDownloadable(true); entityManager.persist(extension); } else { @@ -168,7 +178,17 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us }); } - private void checkDependencies(List dependencies) { + private boolean isMalicious(String namespace, String extension) { + try { + var maliciousExtensionIds = extensionControl.getMaliciousExtensionIds(); + return maliciousExtensionIds.contains(NamingUtil.toExtensionId(namespace, extension)); + } catch(IOException e) { + logger.warn("Failed to check whether extension is malicious or not", e); + return false; + } + } + + private void checkDependencies(List dependencies) { Observation.createNotStarted("PublishExtensionVersionHandler#checkDependencies", observations).observe(() -> { var unresolvedDependency = repositories.findFirstUnresolvedDependency(dependencies); if (unresolvedDependency != null) { @@ -177,13 +197,13 @@ private void checkDependencies(List dependencies) { }); } - private String[] parseExtensionId(String extensionId, String formatType) { - var split = extensionId.split("\\."); - if (split.length != 2 || split[0].isEmpty() || split[1].isEmpty()) { + private ExtensionId parseExtensionId(String extensionIdText, String formatType) { + var extensionId = NamingUtil.fromExtensionId(extensionIdText); + if (extensionId == null) { throw new ErrorResultException("Invalid '" + formatType + "' format. Expected: '${namespace}.${name}'"); } - return split; + return extensionId; } @Async diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java index ae3574cd3..9a5d24ff7 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionJooqRepository.java @@ -11,6 +11,7 @@ import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.web.SitemapRow; import org.jooq.Record; import org.jooq.*; @@ -230,12 +231,12 @@ public List findActiveExtensionNames(Namespace namespace) { .fetch(EXTENSION.NAME); } - public String findFirstUnresolvedDependency(List dependencies) { + public String findFirstUnresolvedDependency(List dependencies) { if(dependencies.isEmpty()) { return null; } - var ids = DSL.values(dependencies.stream().map(d -> DSL.row(d[0], d[1])).toArray(Row2[]::new)).as("ids", "namespace", "extension"); + var ids = DSL.values(dependencies.stream().map(d -> DSL.row(d.namespace(), d.extension())).toArray(Row2[]::new)).as("ids", "namespace", "extension"); var namespace = ids.field("namespace", String.class); var extension = ids.field("extension", String.class); var unresolvedDependency = DSL.concat(namespace, DSL.value("."), extension).as("unresolved_dependency"); @@ -261,4 +262,14 @@ public List findActiveExtensionsForUrls(Namespace namespace) { return extension; }); } + + public boolean hasExtension(String namespace, String extension) { + return dsl.fetchExists( + dsl.selectOne() + .from(NAMESPACE) + .join(EXTENSION).on(EXTENSION.NAMESPACE_ID.eq(NAMESPACE.ID)) + .where(NAMESPACE.NAME.equalIgnoreCase(namespace)) + .and(EXTENSION.NAME.equalIgnoreCase(extension)) + ); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java index e2d73510a..8de496b14 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java @@ -48,4 +48,6 @@ public interface ExtensionRepository extends Repository { @Query("select e from Extension e where concat(e.namespace.name, '.', e.name) not in(?1)") Streamable findAllNotMatchingByExtensionId(List extensionIds); + + Streamable findByReplacement(Extension replacement); } \ No newline at end of file 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 e93d62472..2631f9588 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/ExtensionVersionJooqRepository.java @@ -337,12 +337,26 @@ public Page findActiveVersions(QueryRequest request) { } totalQuery.addConditions(conditions); + query.addSelect(EXTENSION.DEPRECATED, EXTENSION.DOWNLOADABLE, EXTENSION.REPLACEMENT_ID); query.addConditions(conditions); query.addOffset(request.offset); query.addLimit(request.size); + var content = query.fetch().map(record -> { + var extVersion = toExtensionVersionFull(record); + extVersion.getExtension().setDeprecated(record.get(EXTENSION.DEPRECATED)); + extVersion.getExtension().setDownloadable(record.get(EXTENSION.DOWNLOADABLE)); + + var replacementId = record.get(EXTENSION.REPLACEMENT_ID); + if(replacementId != null) { + var replacement = new Extension(); + replacement.setId(replacementId); + extVersion.getExtension().setReplacement(replacement); + } + return extVersion; + }); var total = totalQuery.fetchOne(totalCol, Integer.class); - return new PageImpl<>(fetch(query), PageRequest.of(request.offset / request.size, request.size), total); + return new PageImpl<>(content, PageRequest.of(request.offset / request.size, request.size), total); } public ExtensionVersion findActiveByVersionAndExtensionNameAndNamespaceName(String version, String extensionName, String namespaceName) { @@ -605,6 +619,43 @@ public List findVersionsForUrls(Extension extension, String ta }); } + public ExtensionVersion findLatestReplacement( + long extensionId, + String targetPlatform, + boolean onlyPreRelease, + boolean onlyActive + ) { + var query = findLatestQuery(targetPlatform, onlyPreRelease, onlyActive); + query.addSelect( + NAMESPACE.ID, + NAMESPACE.NAME, + EXTENSION.NAME, + EXTENSION.ACTIVE, + EXTENSION_VERSION.ID, + EXTENSION_VERSION.DISPLAY_NAME + ); + query.addJoin(EXTENSION, EXTENSION.ID.eq(EXTENSION_VERSION.EXTENSION_ID)); + query.addJoin(NAMESPACE, NAMESPACE.ID.eq(EXTENSION.NAMESPACE_ID)); + query.addConditions(EXTENSION_VERSION.EXTENSION_ID.eq(extensionId)); + return query.fetchOne((record) -> { + var namespace = new Namespace(); + namespace.setId(record.get(NAMESPACE.ID)); + namespace.setName(record.get(NAMESPACE.NAME)); + + var extension = new Extension(); + extension.setId(extensionId); + extension.setName(record.get(EXTENSION.NAME)); + extension.setActive(record.get(EXTENSION.ACTIVE)); + extension.setNamespace(namespace); + + var extVersion = new ExtensionVersion(); + extVersion.setId(record.get(EXTENSION_VERSION.ID)); + extVersion.setDisplayName(record.get(EXTENSION_VERSION.DISPLAY_NAME)); + extVersion.setExtension(extension); + return extVersion; + }); + } + public ExtensionVersion findLatest( Extension extension, String targetPlatform, @@ -677,6 +728,7 @@ public ExtensionVersion findLatest( EXTENSION.PUBLISHED_DATE, EXTENSION.LAST_UPDATED_DATE, EXTENSION.ACTIVE, + EXTENSION.DEPRECATED, USER_DATA.ID, USER_DATA.ROLE, USER_DATA.LOGIN_NAME, @@ -723,6 +775,7 @@ public ExtensionVersion findLatest( return query.fetchOne(record -> { var extVersion = toExtensionVersionFull(record); extVersion.getExtension().setActive(record.get(EXTENSION.ACTIVE)); + extVersion.getExtension().setDeprecated(record.get(EXTENSION.DEPRECATED)); extVersion.getExtension().getNamespace().setDisplayName(record.get(NAMESPACE.DISPLAY_NAME)); return extVersion; }); @@ -798,6 +851,7 @@ public List findLatest(Collection extensionIds) { EXTENSION.DOWNLOAD_COUNT, EXTENSION.PUBLISHED_DATE, EXTENSION.LAST_UPDATED_DATE, + EXTENSION.DEPRECATED, latest.field(EXTENSION_VERSION.ID), latest.field(EXTENSION_VERSION.POTENTIALLY_MALICIOUS), latest.field(EXTENSION_VERSION.VERSION), @@ -839,7 +893,11 @@ public List findLatest(Collection extensionIds) { 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(EXTENSION.ID.in(extensionIds)); - return query.fetch(record -> toExtensionVersionFull(record, null, new TableFieldMapper(latest))); + return query.fetch(record -> { + var extVersion = toExtensionVersionFull(record, null, new TableFieldMapper(latest)); + extVersion.getExtension().setDeprecated(record.get(EXTENSION.DEPRECATED)); + return extVersion; + }); } public List findLatest(Namespace namespace) { @@ -864,6 +922,7 @@ public List findLatest(Namespace namespace) { EXTENSION.AVERAGE_RATING, EXTENSION.REVIEW_COUNT, EXTENSION.DOWNLOAD_COUNT, + EXTENSION.DEPRECATED, latest.field(EXTENSION_VERSION.ID), latest.field(EXTENSION_VERSION.VERSION), latest.field(EXTENSION_VERSION.TARGET_PLATFORM), @@ -886,6 +945,7 @@ public List findLatest(Namespace namespace) { extension.setAverageRating(record.get(EXTENSION.AVERAGE_RATING)); extension.setReviewCount(record.get(EXTENSION.REVIEW_COUNT)); extension.setDownloadCount(record.get(EXTENSION.DOWNLOAD_COUNT)); + extension.setDeprecated(record.get(EXTENSION.DEPRECATED)); extension.setNamespace(namespace); var extVersion = new ExtensionVersion(); @@ -954,6 +1014,8 @@ public List findLatest(UserData user) { 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), @@ -999,6 +1061,8 @@ public List findLatest(UserData user) { var extVersion = toExtensionVersionFull(record, null, new TableFieldMapper(latest)); extVersion.getExtension().getNamespace().setDisplayName(record.get(NAMESPACE.DISPLAY_NAME)); extVersion.getExtension().setActive(record.get(EXTENSION.ACTIVE)); + extVersion.getExtension().setDeprecated(record.get(EXTENSION.DEPRECATED)); + extVersion.getExtension().setDownloadable(record.get(EXTENSION.DOWNLOADABLE)); return extVersion; }); } @@ -1083,6 +1147,9 @@ public ExtensionVersion find(String namespaceName, String extensionName, String EXTENSION.DOWNLOAD_COUNT, EXTENSION.PUBLISHED_DATE, EXTENSION.LAST_UPDATED_DATE, + EXTENSION.DEPRECATED, + EXTENSION.DOWNLOADABLE, + EXTENSION.REPLACEMENT_ID, EXTENSION_VERSION.ID, EXTENSION_VERSION.VERSION, EXTENSION_VERSION.POTENTIALLY_MALICIOUS, @@ -1125,7 +1192,16 @@ public ExtensionVersion find(String namespaceName, String extensionName, String return query.fetchOne((record) -> { var extVersion = toExtensionVersionFull(record); + extVersion.getExtension().setDeprecated(record.get(EXTENSION.DEPRECATED)); + extVersion.getExtension().setDownloadable(record.get(EXTENSION.DOWNLOADABLE)); extVersion.getExtension().getNamespace().setDisplayName(record.get(NAMESPACE.DISPLAY_NAME)); + + var replacementId = record.get(EXTENSION.REPLACEMENT_ID); + if(replacementId != null) { + var replacement = new Extension(); + replacement.setId(replacementId); + extVersion.getExtension().setReplacement(replacement); + } return extVersion; }); } 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 62cdf8b23..ee45de867 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -14,6 +14,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; import org.eclipse.openvsx.json.VersionTargetPlatformsJson; +import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.util.NamingUtil; import org.eclipse.openvsx.web.SitemapRow; import org.springframework.data.domain.Page; @@ -687,7 +688,7 @@ public String findSignatureKeyPairPublicId(String namespace, String extension, S return signatureKeyPairJooqRepo.findPublicId(namespace, extension, targetPlatform, version); } - public String findFirstUnresolvedDependency(List dependencies) { + public String findFirstUnresolvedDependency(List dependencies) { return extensionJooqRepo.findFirstUnresolvedDependency(dependencies); } @@ -698,4 +699,16 @@ public PersonalAccessToken findAccessToken(UserData user, String description) { public NamespaceMembership findFirstMembership(String namespaceName) { return membershipRepo.findFirstByNamespaceNameIgnoreCase(namespaceName); } + + public ExtensionVersion findLatestReplacement(long extensionId, String targetPlatform, boolean onlyPreRelease, boolean onlyActive) { + return extensionVersionJooqRepo.findLatestReplacement(extensionId, targetPlatform, onlyPreRelease, onlyActive); + } + + public boolean hasExtension(String namespace, String extension) { + return extensionJooqRepo.hasExtension(namespace, extension); + } + + public Streamable findDeprecatedExtensions(Extension replacement) { + return extensionRepo.findByReplacement(replacement); + } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/search/RelevanceService.java b/server/src/main/java/org/eclipse/openvsx/search/RelevanceService.java index c717f4aa2..46508337f 100644 --- a/server/src/main/java/org/eclipse/openvsx/search/RelevanceService.java +++ b/server/src/main/java/org/eclipse/openvsx/search/RelevanceService.java @@ -45,6 +45,8 @@ public class RelevanceService { double timestampRelevance; @Value("${ovsx.search.relevance.unverified:0.5}") double unverifiedRelevance; + @Value("${ovsx.search.relevance.deprecated:0.5}") + double deprecatedRelevance; @Value("${ovsx.elasticsearch.relevance.rating:-1.0}") double deprecatedElasticSearchRatingRelevance; @@ -117,6 +119,11 @@ private double calculateRelevance(Extension extension, ExtensionVersion latest, relevance *= unverifiedRelevance; } + // Reduce the relevance value of deprecated extensions + if (extension.isDeprecated()) { + relevance *= deprecatedRelevance; + } + if (Double.isNaN(entry.relevance) || Double.isInfinite(entry.relevance)) { var message = "Invalid relevance for entry " + NamingUtil.toExtensionId(entry); try { diff --git a/server/src/main/java/org/eclipse/openvsx/util/ExtensionId.java b/server/src/main/java/org/eclipse/openvsx/util/ExtensionId.java new file mode 100644 index 000000000..bc59f0522 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/util/ExtensionId.java @@ -0,0 +1,12 @@ +/** ****************************************************************************** + * Copyright (c) 2024 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.util; + +public record ExtensionId(String namespace, String extension) {} diff --git a/server/src/main/java/org/eclipse/openvsx/util/NamingUtil.java b/server/src/main/java/org/eclipse/openvsx/util/NamingUtil.java index 142a211cd..ba9375975 100644 --- a/server/src/main/java/org/eclipse/openvsx/util/NamingUtil.java +++ b/server/src/main/java/org/eclipse/openvsx/util/NamingUtil.java @@ -85,4 +85,12 @@ public static String toExtensionId(ExtensionSearch search) { public static String toExtensionId(String namespace, String extension) { return namespace + "." + extension; } + + public static ExtensionId fromExtensionId(String text) { + var split = text.split("\\."); + return split.length == 2 && !split[0].isEmpty() && !split[1].isEmpty() + ? new ExtensionId(split[0], split[1]) + : null; + } + } diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java index 3b52edc0c..7d0433ac2 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Keys.java @@ -120,6 +120,7 @@ public class Keys { public static final ForeignKey ADMIN_STATISTICS_TOP_MOST_DOWNLOADED_EXTENSIONS__ADMIN_STATISTICS_TOP_MOST_DOWNLOADED_EXTENSIONS_FKEY = Internal.createForeignKey(AdminStatisticsTopMostDownloadedExtensions.ADMIN_STATISTICS_TOP_MOST_DOWNLOADED_EXTENSIONS, DSL.name("admin_statistics_top_most_downloaded_extensions_fkey"), new TableField[] { AdminStatisticsTopMostDownloadedExtensions.ADMIN_STATISTICS_TOP_MOST_DOWNLOADED_EXTENSIONS.ADMIN_STATISTICS_ID }, Keys.ADMIN_STATISTICS_PKEY, new TableField[] { AdminStatistics.ADMIN_STATISTICS.ID }, true); public static final ForeignKey ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSION_VERSIONS__ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSION_VERSIONS_FKEY = Internal.createForeignKey(AdminStatisticsTopNamespaceExtensionVersions.ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSION_VERSIONS, DSL.name("admin_statistics_top_namespace_extension_versions_fkey"), new TableField[] { AdminStatisticsTopNamespaceExtensionVersions.ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSION_VERSIONS.ADMIN_STATISTICS_ID }, Keys.ADMIN_STATISTICS_PKEY, new TableField[] { AdminStatistics.ADMIN_STATISTICS.ID }, true); public static final ForeignKey ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSIONS__ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSIONS_FKEY = Internal.createForeignKey(AdminStatisticsTopNamespaceExtensions.ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSIONS, DSL.name("admin_statistics_top_namespace_extensions_fkey"), new TableField[] { AdminStatisticsTopNamespaceExtensions.ADMIN_STATISTICS_TOP_NAMESPACE_EXTENSIONS.ADMIN_STATISTICS_ID }, Keys.ADMIN_STATISTICS_PKEY, new TableField[] { AdminStatistics.ADMIN_STATISTICS.ID }, true); + public static final ForeignKey EXTENSION__EXTENSION_REPLACEMENT_ID_FKEY = Internal.createForeignKey(Extension.EXTENSION, DSL.name("extension_replacement_id_fkey"), new TableField[] { Extension.EXTENSION.REPLACEMENT_ID }, Keys.EXTENSION_PKEY, new TableField[] { Extension.EXTENSION.ID }, true); public static final ForeignKey EXTENSION__FK64IMD3NRJ67D50TPKJS94NGMN = Internal.createForeignKey(Extension.EXTENSION, DSL.name("fk64imd3nrj67d50tpkjs94ngmn"), new TableField[] { Extension.EXTENSION.NAMESPACE_ID }, Keys.NAMESPACE_PKEY, new TableField[] { Namespace.NAMESPACE.ID }, true); public static final ForeignKey EXTENSION_REVIEW__FKGD2DQDC23OGBNOBX8AFJFPNKP = Internal.createForeignKey(ExtensionReview.EXTENSION_REVIEW, DSL.name("fkgd2dqdc23ogbnobx8afjfpnkp"), new TableField[] { ExtensionReview.EXTENSION_REVIEW.EXTENSION_ID }, Keys.EXTENSION_PKEY, new TableField[] { Extension.EXTENSION.ID }, true); public static final ForeignKey EXTENSION_REVIEW__FKINJBN9GRK135Y6IK0UT4UJP0W = Internal.createForeignKey(ExtensionReview.EXTENSION_REVIEW, DSL.name("fkinjbn9grk135y6ik0ut4ujp0w"), new TableField[] { ExtensionReview.EXTENSION_REVIEW.USER_ID }, Keys.USER_DATA_PKEY, new TableField[] { UserData.USER_DATA.ID }, true); diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/Extension.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/Extension.java index 059b31ec2..391e4a15a 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/Extension.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/Extension.java @@ -15,12 +15,12 @@ import org.eclipse.openvsx.jooq.tables.records.ExtensionRecord; import org.jooq.Field; import org.jooq.ForeignKey; -import org.jooq.Function10; +import org.jooq.Function13; import org.jooq.Index; import org.jooq.Name; import org.jooq.Record; import org.jooq.Records; -import org.jooq.Row10; +import org.jooq.Row13; import org.jooq.Schema; import org.jooq.SelectField; import org.jooq.Table; @@ -103,6 +103,21 @@ public Class getRecordType() { */ public final TableField REVIEW_COUNT = createField(DSL.name("review_count"), SQLDataType.BIGINT, this, ""); + /** + * The column public.extension.deprecated. + */ + public final TableField DEPRECATED = createField(DSL.name("deprecated"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column public.extension.replacement_id. + */ + public final TableField REPLACEMENT_ID = createField(DSL.name("replacement_id"), SQLDataType.BIGINT, this, ""); + + /** + * The column public.extension.downloadable. + */ + public final TableField DOWNLOADABLE = createField(DSL.name("downloadable"), SQLDataType.BOOLEAN.nullable(false), this, ""); + private Extension(Name alias, Table aliased) { this(alias, aliased, null); } @@ -158,10 +173,11 @@ public List> getUniqueKeys() { @Override public List> getReferences() { - return Arrays.asList(Keys.EXTENSION__FK64IMD3NRJ67D50TPKJS94NGMN); + return Arrays.asList(Keys.EXTENSION__FK64IMD3NRJ67D50TPKJS94NGMN, Keys.EXTENSION__EXTENSION_REPLACEMENT_ID_FKEY); } private transient Namespace _namespace; + private transient Extension _extension; /** * Get the implicit join path to the public.namespace table. @@ -173,6 +189,16 @@ public Namespace namespace() { return _namespace; } + /** + * Get the implicit join path to the public.extension table. + */ + public Extension extension() { + if (_extension == null) + _extension = new Extension(this, Keys.EXTENSION__EXTENSION_REPLACEMENT_ID_FKEY); + + return _extension; + } + @Override public Extension as(String alias) { return new Extension(DSL.name(alias), this); @@ -213,18 +239,18 @@ public Extension rename(Table name) { } // ------------------------------------------------------------------------- - // Row10 type methods + // Row13 type methods // ------------------------------------------------------------------------- @Override - public Row10 fieldsRow() { - return (Row10) super.fieldsRow(); + public Row13 fieldsRow() { + return (Row13) super.fieldsRow(); } /** * Convenience mapping calling {@link SelectField#convertFrom(Function)}. */ - public SelectField mapping(Function10 from) { + public SelectField mapping(Function13 from) { return convertFrom(Records.mapping(from)); } @@ -232,7 +258,7 @@ public SelectField mapping(Function10 SelectField mapping(Class toType, Function10 from) { + public SelectField mapping(Class toType, Function13 from) { return convertFrom(toType, Records.mapping(from)); } } diff --git a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/ExtensionRecord.java b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/ExtensionRecord.java index c3157673a..20ddc5fcf 100644 --- a/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/ExtensionRecord.java +++ b/server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/ExtensionRecord.java @@ -9,8 +9,8 @@ import org.eclipse.openvsx.jooq.tables.Extension; import org.jooq.Field; import org.jooq.Record1; -import org.jooq.Record10; -import org.jooq.Row10; +import org.jooq.Record13; +import org.jooq.Row13; import org.jooq.impl.UpdatableRecordImpl; @@ -18,7 +18,7 @@ * This class is generated by jOOQ. */ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) -public class ExtensionRecord extends UpdatableRecordImpl implements Record10 { +public class ExtensionRecord extends UpdatableRecordImpl implements Record13 { private static final long serialVersionUID = 1L; @@ -162,6 +162,48 @@ public Long getReviewCount() { return (Long) get(9); } + /** + * Setter for public.extension.deprecated. + */ + public void setDeprecated(Boolean value) { + set(10, value); + } + + /** + * Getter for public.extension.deprecated. + */ + public Boolean getDeprecated() { + return (Boolean) get(10); + } + + /** + * Setter for public.extension.replacement_id. + */ + public void setReplacementId(Long value) { + set(11, value); + } + + /** + * Getter for public.extension.replacement_id. + */ + public Long getReplacementId() { + return (Long) get(11); + } + + /** + * Setter for public.extension.downloadable. + */ + public void setDownloadable(Boolean value) { + set(12, value); + } + + /** + * Getter for public.extension.downloadable. + */ + public Boolean getDownloadable() { + return (Boolean) get(12); + } + // ------------------------------------------------------------------------- // Primary key information // ------------------------------------------------------------------------- @@ -172,17 +214,17 @@ public Record1 key() { } // ------------------------------------------------------------------------- - // Record10 type implementation + // Record13 type implementation // ------------------------------------------------------------------------- @Override - public Row10 fieldsRow() { - return (Row10) super.fieldsRow(); + public Row13 fieldsRow() { + return (Row13) super.fieldsRow(); } @Override - public Row10 valuesRow() { - return (Row10) super.valuesRow(); + public Row13 valuesRow() { + return (Row13) super.valuesRow(); } @Override @@ -235,6 +277,21 @@ public Field field10() { return Extension.EXTENSION.REVIEW_COUNT; } + @Override + public Field field11() { + return Extension.EXTENSION.DEPRECATED; + } + + @Override + public Field field12() { + return Extension.EXTENSION.REPLACEMENT_ID; + } + + @Override + public Field field13() { + return Extension.EXTENSION.DOWNLOADABLE; + } + @Override public Long component1() { return getId(); @@ -285,6 +342,21 @@ public Long component10() { return getReviewCount(); } + @Override + public Boolean component11() { + return getDeprecated(); + } + + @Override + public Long component12() { + return getReplacementId(); + } + + @Override + public Boolean component13() { + return getDownloadable(); + } + @Override public Long value1() { return getId(); @@ -335,6 +407,21 @@ public Long value10() { return getReviewCount(); } + @Override + public Boolean value11() { + return getDeprecated(); + } + + @Override + public Long value12() { + return getReplacementId(); + } + + @Override + public Boolean value13() { + return getDownloadable(); + } + @Override public ExtensionRecord value1(Long value) { setId(value); @@ -396,7 +483,25 @@ public ExtensionRecord value10(Long value) { } @Override - public ExtensionRecord values(Long value1, Double value2, Integer value3, String value4, Long value5, String value6, Boolean value7, LocalDateTime value8, LocalDateTime value9, Long value10) { + public ExtensionRecord value11(Boolean value) { + setDeprecated(value); + return this; + } + + @Override + public ExtensionRecord value12(Long value) { + setReplacementId(value); + return this; + } + + @Override + public ExtensionRecord value13(Boolean value) { + setDownloadable(value); + return this; + } + + @Override + public ExtensionRecord values(Long value1, Double value2, Integer value3, String value4, Long value5, String value6, Boolean value7, LocalDateTime value8, LocalDateTime value9, Long value10, Boolean value11, Long value12, Boolean value13) { value1(value1); value2(value2); value3(value3); @@ -407,6 +512,9 @@ public ExtensionRecord values(Long value1, Double value2, Integer value3, String value8(value8); value9(value9); value10(value10); + value11(value11); + value12(value12); + value13(value13); return this; } @@ -424,7 +532,7 @@ public ExtensionRecord() { /** * Create a detached, initialised ExtensionRecord */ - public ExtensionRecord(Long id, Double averageRating, Integer downloadCount, String name, Long namespaceId, String publicId, Boolean active, LocalDateTime publishedDate, LocalDateTime lastUpdatedDate, Long reviewCount) { + public ExtensionRecord(Long id, Double averageRating, Integer downloadCount, String name, Long namespaceId, String publicId, Boolean active, LocalDateTime publishedDate, LocalDateTime lastUpdatedDate, Long reviewCount, Boolean deprecated, Long replacementId, Boolean downloadable) { super(Extension.EXTENSION); setId(id); @@ -437,6 +545,9 @@ public ExtensionRecord(Long id, Double averageRating, Integer downloadCount, Str setPublishedDate(publishedDate); setLastUpdatedDate(lastUpdatedDate); setReviewCount(reviewCount); + setDeprecated(deprecated); + setReplacementId(replacementId); + setDownloadable(downloadable); resetChangedOnNotNull(); } } diff --git a/server/src/main/resources/db/migration/V1_47__Deprecated_Extension.sql b/server/src/main/resources/db/migration/V1_47__Deprecated_Extension.sql new file mode 100644 index 000000000..69a969138 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_47__Deprecated_Extension.sql @@ -0,0 +1,12 @@ +ALTER TABLE public.extension ADD COLUMN deprecated BOOLEAN; +ALTER TABLE public.extension ADD COLUMN replacement_id BIGINT; +ALTER TABLE public.extension ADD COLUMN downloadable BOOLEAN; + +UPDATE public.extension SET deprecated = FALSE; +UPDATE public.extension SET downloadable = TRUE; + +ALTER TABLE public.extension ALTER COLUMN deprecated SET NOT NULL; +ALTER TABLE public.extension ALTER COLUMN downloadable SET NOT NULL; + +ALTER TABLE public.extension ADD CONSTRAINT extension_replacement_id_fkey +FOREIGN KEY (replacement_id) REFERENCES public.extension(id); \ No newline at end of file diff --git a/server/src/main/resources/ehcache.xml b/server/src/main/resources/ehcache.xml index 0508035c0..8eaeccb9d 100644 --- a/server/src/main/resources/ehcache.xml +++ b/server/src/main/resources/ehcache.xml @@ -70,4 +70,14 @@ 8 + + + 1 + + + 1 + 2 + 8 + + \ 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 77717fc71..dd2e424dc 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -20,6 +20,7 @@ import org.eclipse.openvsx.cache.LatestExtensionVersionCacheKeyGenerator; import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.extension_control.ExtensionControlService; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.publish.ExtensionVersionIntegrityService; import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; @@ -84,7 +85,8 @@ @MockBean({ ClientRegistrationRepository.class, UpstreamRegistryService.class, GoogleCloudStorageService.class, AzureBlobStorageService.class, VSCodeIdService.class, AzureDownloadCountService.class, CacheService.class, - EclipseService.class, PublishExtensionVersionService.class, SimpleMeterRegistry.class, JobRequestScheduler.class + EclipseService.class, PublishExtensionVersionService.class, SimpleMeterRegistry.class, JobRequestScheduler.class, + ExtensionControlService.class }) public class RegistryAPITest { @@ -1382,6 +1384,7 @@ public void testPublishRequireLicenseOk() throws Exception { u.loginName = "test_user"; e.publishedBy = u; e.verified = true; + e.downloadable = true; }))); } finally { extensions.requireLicense = previousRequireLicense; @@ -1428,6 +1431,7 @@ public void testPublishVerifiedOwner() throws Exception { u.loginName = "test_user"; e.publishedBy = u; e.verified = true; + e.downloadable = true; }))); } @@ -1448,6 +1452,7 @@ public void testPublishVerifiedContributor() throws Exception { u.loginName = "test_user"; e.publishedBy = u; e.verified = true; + e.downloadable = true; }))); } @@ -1468,6 +1473,7 @@ public void testPublishSoleContributor() throws Exception { u.loginName = "test_user"; e.publishedBy = u; e.verified = false; + e.downloadable = true; }))); } @@ -1488,6 +1494,7 @@ public void testPublishRestrictedPrivileged() throws Exception { u.loginName = "test_user"; e.publishedBy = u; e.verified = true; + e.downloadable = true; }))); } @@ -2417,6 +2424,7 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( JobRequestScheduler scheduler, UserService users, ExtensionValidator validator, + ExtensionControlService extensionControl, ObservationRegistry observations ) { return new PublishExtensionVersionHandler( @@ -2427,6 +2435,7 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( scheduler, users, validator, + extensionControl, observations ); } 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 5f6f1b5db..c9b6ec062 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -1154,6 +1154,8 @@ private List mockExtension(int numberOfVersions, int numberOfB Mockito.when(repositories.findAllReviews(extension)) .thenReturn(Streamable.empty()); + Mockito.when(repositories.findDeprecatedExtensions(extension)) + .thenReturn(Streamable.empty()); return versions; } 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 16144ae73..8826b860e 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -13,6 +13,7 @@ import jakarta.transaction.Transactional; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; +import org.eclipse.openvsx.util.ExtensionId; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.mockito.invocation.Invocation; @@ -217,12 +218,16 @@ void testExecuteQueries() { () -> repositories.canPublishInNamespace(userData, namespace), () -> repositories.findLatestVersion("namespaceName", "extensionName", "targetPlatform", false, false), () -> repositories.hasMembership(userData, namespace), - () -> repositories.findFirstUnresolvedDependency(List.of(new String[]{"namespaceName", "extensionName"})), + () -> repositories.findFirstUnresolvedDependency(List.of(new ExtensionId("namespaceName", "extensionName"))), () -> repositories.findAllAccessTokens(), () -> repositories.hasAccessToken("tokenValue"), () -> repositories.findSignatureKeyPairPublicId("namespaceName", "extensionName", "targetPlatform", "version"), () -> repositories.findFirstMembership("namespaceName"), - () -> repositories.findActiveExtensionsForUrls(namespace) + () -> repositories.findActiveExtensionsForUrls(namespace), + () -> repositories.deactivateKeyPairs(), + () -> repositories.hasExtension("namespaceName", "extensionName"), + () -> repositories.findDeprecatedExtensions(extension), + () -> repositories.findLatestReplacement(1L, null, false, false) ); // check that we did not miss anything diff --git a/webui/package.json b/webui/package.json index 6d02a1793..1cba5ff36 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "openvsx-webui", - "version": "0.11.11", + "version": "0.12.0", "description": "User interface for Eclipse Open VSX", "keywords": [ "react", diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index fa045029a..569125632 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -57,6 +57,7 @@ export interface SearchEntry { downloadCount?: number; displayName?: string; description?: string; + deprecated: boolean; } export const VERSION_ALIASES = ['latest', 'pre-release']; @@ -108,6 +109,13 @@ export interface Extension { // key: target platform, value: download link downloads: { [targetPlatform: string]: UrlString }; allTargetPlatformVersions?: VersionTargetPlatforms[]; + + deprecated: boolean + replacement?: { + url: string + displayName: string + } + downloadable: boolean } export interface Badge { diff --git a/webui/src/pages/admin-dashboard/extension-version-container.tsx b/webui/src/pages/admin-dashboard/extension-version-container.tsx index bc1884a5d..8cf307b15 100644 --- a/webui/src/pages/admin-dashboard/extension-version-container.tsx +++ b/webui/src/pages/admin-dashboard/extension-version-container.tsx @@ -11,6 +11,7 @@ import React, { ChangeEvent, FunctionComponent, useContext, useState, useEffect, useRef } from 'react'; import { Extension, TargetPlatformVersion, VERSION_ALIASES } from '../../extension-registry-types'; import { Box, Grid, Typography, FormControl, FormGroup, FormControlLabel, Checkbox } from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; import { ExtensionRemoveDialog } from './extension-remove-dialog'; import { getTargetPlatformDisplayName } from '../../utils'; import { MainContext } from '../../context'; @@ -98,7 +99,7 @@ export const ExtensionVersionContainer: FunctionComponent - + { icon ? @@ -121,6 +122,16 @@ export const ExtensionVersionContainer: FunctionComponent + { extension.deprecated && + + + + + +  This extension has been deprecated. + + + } {extension.namespace}.{extension.name} diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index 48a53b841..eca5efef6 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -308,9 +308,9 @@ export const ExtensionDetailOverview: FunctionComponent 1 ? + extension.downloadable && extension.downloads && Object.keys(extension.downloads).length > 1 ? - : extension.downloads && Object.keys(extension.downloads).length == 1 ? + : extension.downloadable && extension.downloads && Object.keys(extension.downloads).length == 1 ?