Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: extension control #988

Merged
merged 1 commit into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading