Skip to content

Commit

Permalink
Feature: extension control
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
amvanbaren committed Sep 20, 2024
1 parent 20c549e commit 6c0bba5
Show file tree
Hide file tree
Showing 37 changed files with 806 additions and 71 deletions.
2 changes: 2 additions & 0 deletions server/src/dev/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 55 additions & 12 deletions server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ public LocalRegistryService(
this.observations = observations;
}

@Value("${ovsx.webui.url:}")
String webuiUrl;

@Value("${ovsx.registry.version:}")
String registryVersion;

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
3 changes: 1 addition & 2 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -197,6 +195,7 @@ public ResponseEntity<ExtensionJson> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down
41 changes: 39 additions & 2 deletions server/src/main/java/org/eclipse/openvsx/entities/Extension.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -163,6 +170,30 @@ public List<ExtensionVersion> 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;
Expand All @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HandlerJobRequest<?>> {

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);
}
});
}
}
Loading

0 comments on commit 6c0bba5

Please sign in to comment.