Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.idea/
.project/
.settings/
.claude/
data/
23 changes: 12 additions & 11 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -265,27 +265,28 @@ test {
}

tasks.register('unitTests', Test) {
description = 'Runs unit tests (excluding integration tests).'
description = 'Runs unit tests (excluding container-backed integration tests).'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform()
exclude 'org/eclipse/openvsx/ExtensionDeleteTest.class'
exclude 'org/eclipse/openvsx/IntegrationTest.class'
exclude 'org/eclipse/openvsx/cache/CacheServiceTest.class'
exclude 'org/eclipse/openvsx/ratelimit/RateLimitIntegrationTest.class'
exclude 'org/eclipse/openvsx/repositories/NamespaceRepositoryTest.class'
exclude 'org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.class'
exclude 'org/eclipse/openvsx/storage/AwsStorageServiceIntegrationTest.class'
// Exclude container-backed tests by tag rather than a hand-maintained class list: any test that
// extends AbstractPostgresContainerTest (or is otherwise tagged "integration") is skipped here, so
// this fast task never spins up a Docker container. New integration tests are covered automatically.
useJUnitPlatform {
excludeTags 'integration'
}
}

tasks.register('s3IntegrationTests', Test) {
description = 'Runs S3 integration tests using LocalStack (requires Docker/Podman).'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform()
include 'org/eclipse/openvsx/storage/AwsStorageServiceIntegrationTest.class'
// Select S3 integration tests by tag rather than a hardcoded class name, so new tests tagged "s3"
// are picked up automatically.
useJUnitPlatform {
includeTags 's3'
}

// Set system properties for test configuration
systemProperty 'spring.profiles.active', 's3-integration'
Expand Down
4 changes: 4 additions & 0 deletions server/config/eclipse-java-formatter.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,9 @@
this (49); parameters default to "never split" (0), so only that one needs overriding. -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_parameter" value="49"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_annotations_on_local_variable" value="49"/>

<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="false"/>
</profile>
</profiles>
254 changes: 183 additions & 71 deletions server/src/main/java/org/eclipse/openvsx/ExtensionService.java

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ public LocalRegistryService(

@Override
public NamespaceJson getNamespace(String namespaceName) {
return getNamespace(namespaceName, false);
}

/**
* Build the namespace JSON. When {@code includeInactive} is {@code true}, all extensions of the
* namespace are listed, including inactive/soft-deleted ones; otherwise only active extensions are
* listed. Inactive extensions must only be exposed on admin surfaces.
*/
public NamespaceJson getNamespace(String namespaceName, boolean includeInactive) {
var namespace = repositories.findNamespace(namespaceName);
if (namespace == null) {
throw new NotFoundException();
Expand All @@ -127,13 +136,15 @@ public NamespaceJson getNamespace(String namespaceName) {
json.setName(namespace.getName());
var extensionsMap = new LinkedHashMap<String, String>();
var serverUrl = UrlUtil.getBaseUrl();
for (var name : repositories.findActiveExtensionNames(namespace)) {
var extensionNames = includeInactive
? repositories.findAllExtensionNames(namespace)
: repositories.findActiveExtensionNames(namespace);
for (var name : extensionNames) {
String url = createApiUrl(serverUrl, "api", namespace.getName(), name);
extensionsMap.put(name, url);
}
json.setExtensions(extensionsMap);
json.setVerified(repositories.hasMemberships(namespace, NamespaceMembership.ROLE_OWNER));
json.setAccess(RESTRICTED_ACCESS);
return json;
}

Expand Down
116 changes: 103 additions & 13 deletions server/src/main/java/org/eclipse/openvsx/UserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
Expand Down Expand Up @@ -53,6 +54,7 @@
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
import org.eclipse.openvsx.json.UsageStatsListJson;
import org.eclipse.openvsx.json.UserJson;
import org.eclipse.openvsx.json.VersionTargetPlatformsJson;
import org.eclipse.openvsx.repositories.ExtensionScanRepository;
import org.eclipse.openvsx.repositories.RepositoryService;
import org.eclipse.openvsx.security.CodedAuthException;
Expand Down Expand Up @@ -146,9 +148,9 @@ public ErrorJson getAuthError(HttpServletRequest request) {
}

/**
* This endpoint is used to check whether there is a logged-in user. For this reason, it
* does not return a 403 status, but an OK status with JSON body when no user data is
* available. This is to avoid unnecessary network error logging in the browser console.
* This endpoint is used to check whether there is a logged-in user. For this reason, it does not return a 403
* status, but an OK status with JSON body when no user data is available. This is to avoid unnecessary network
* error logging in the browser console.
*/
@GetMapping(
path = "/user",
Expand Down Expand Up @@ -236,6 +238,17 @@ public ResponseEntity<ResultJson> deleteAccessToken(@PathVariable long id) {
}
}

/**
* Lists the extensions shown in the authenticated user's settings view.
* <p>
* Only extensions the user published <em>and</em> whose namespace the user is <em>currently</em>
* a member of are returned. Extensions the user published in a namespace they have since left
* (or been removed from) are excluded, since the user no longer has any access to them (see
* {@link #getOwnExtension}). The list includes inactive and removed (soft-deleted) versions of
* the extensions that do qualify.
*
* @return {@code 200 OK} with the list of extensions, or {@code 403 Forbidden} if not logged in
*/
@GetMapping(
path = "/user/extensions",
produces = MediaType.APPLICATION_JSON_VALUE
Expand All @@ -246,7 +259,14 @@ public List<ExtensionJson> getOwnExtensions() {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}

var extVersions = repositories.findLatestVersions(user);
// Restrict to namespaces the user is currently a member of: a user who left a namespace must
// no longer see extensions they published there, even though they remain the publisher.
var memberNamespaceIds = repositories.findMemberships(user).stream()
.map(membership -> membership.getNamespace().getId())
.collect(Collectors.toSet());
var extVersions = repositories.findLatestVersions(user).stream()
.filter(ev -> memberNamespaceIds.contains(ev.getExtension().getNamespace().getId()))
.toList();

var types = new String[] { DOWNLOAD, MANIFEST, ICON, README, LICENSE, CHANGELOG, VSIXMANIFEST };
var fileUrls = storageUtil.getFileUrls(extVersions, UrlUtil.getBaseUrl(), types);
Expand All @@ -255,6 +275,7 @@ public List<ExtensionJson> getOwnExtensions() {
var json = latest.toExtensionJson();
json.setPreview(latest.isPreview());
json.setActive(latest.getExtension().isActive());
json.setRemoved(latest.isRemoved());
json.setFiles(fileUrls.get(latest.getId()));

// Add scan/review status information
Expand All @@ -269,9 +290,11 @@ public List<ExtensionJson> getOwnExtensions() {
* Add review/scan status information to the extension JSON.
* <p>
* This shows users the current state of their extension in simple terms:
* - "published" - Extension is active and publicly available
* - "under_review" - Extension is being reviewed (validation, scanning, etc.)
* - "rejected" - Extension was blocked (quarantined or rejected)
* <ul>
* <li>"published" - Extension is active and publicly available</li>
* <li>"under_review" - Extension is being reviewed (validation, scanning, etc.)</li>
* <li>"rejected" - Extension was blocked (quarantined or rejected)</li>
* </ul>
*/
private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVersion) {
// Look up scan by extension metadata (namespace, name, version, platform)
Expand All @@ -284,13 +307,18 @@ private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVers
extVersion.getTargetPlatform());

if (Boolean.TRUE.equals(json.getActive())) {
// Only mark published if scan result indicates PASSED or no scan result exists (scanning disabled / manual activation)
// Only mark published if scan result indicates PASSED or no scan result exists (scanning disabled / manual
// activation)
if (scanResult == null || scanResult.getStatus() == ScanStatus.PASSED) {
json.setReviewStatus("published");
return;
}
}

if (extVersion.isRemoved()) {
return;
}

if (scanResult == null) {
// No scan result found - show as under review
json.setReviewStatus("under_review");
Expand Down Expand Up @@ -358,6 +386,26 @@ private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVers
}
}

/**
* Returns an extension for the authenticated user's settings view, including every version's
* target platforms and, per version, whether the caller may delete it.
* <p>
* Access is restricted to <em>current</em> namespace members: a member (owner or not) sees
* <em>all</em> versions of the extension, including versions they did not publish themselves and
* removed (soft-deleted) ones. A user who is not a member of the namespace has no access and
* receives {@code 404 Not Found}, even for versions they published while they were still a member.
* <p>
* Each returned version carries a {@code canDelete} flag mirroring the authorization enforced by
* {@link #deleteExtension}: owners may delete any version, other members only the versions they
* published themselves. This lets the settings UI disable delete controls the caller is not
* allowed to use.
*
* @param namespaceName the namespace of the extension
* @param extensionName the extension name
* @return {@code 200 OK} with the extension, {@code 403 Forbidden} if not logged in, or
* {@code 404 Not Found} if the caller is not a namespace member or the extension does
* not exist
*/
@GetMapping(
path = "/user/extension/{namespaceName}/{extensionName}",
produces = MediaType.APPLICATION_JSON_VALUE
Expand All @@ -372,13 +420,39 @@ public ResponseEntity<ExtensionJson> getOwnExtension(
}

try {
var namespace = repositories.findNamespace(namespaceName);
// Only current namespace members may inspect an extension here. A user who left the
// namespace (or was never a member) has no access, even to versions they published
// themselves. Members see every version, including ones they did not publish.
var isOwner = namespace != null && repositories.isNamespaceOwner(user, namespace);
var isMember = isOwner || (namespace != null && repositories.hasMembership(user, namespace));
if (!isMember) {
var error = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName);
throw new ErrorResultException(error, HttpStatus.NOT_FOUND);
}

var latest = repositories.findLatestVersion(namespaceName, extensionName, null, false, false);

ExtensionJson json;
var latest = repositories.findLatestVersion(user, namespaceName, extensionName);
if (latest != null) {
json = local.toExtensionVersionJson(latest, null, false);
var extension = latest.getExtension();
var allVersions = repositories.findTargetPlatformsGroupedByVersion(extension);
// Annotate each version with whether the caller may delete it, mirroring deleteExtension:
// owners may delete any version, other members only the versions they published themselves.
var deletableVersions = isOwner
? null
: repositories.findTargetPlatformsGroupedByVersion(extension, user).stream()
.map(VersionTargetPlatformsJson::version)
.collect(Collectors.toSet());
json.setAllTargetPlatformVersions(
repositories.findTargetPlatformsGroupedByVersion(latest.getExtension(), user));
json.setActive(latest.getExtension().isActive());
allVersions.stream()
.map(
v -> v.withCanDelete(
deletableVersions == null || deletableVersions.contains(v.version())))
.toList());
json.setActive(extension.isActive());
json.setRemoved(latest.isRemoved());
} else {
var error = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName);
throw new ErrorResultException(error, HttpStatus.NOT_FOUND);
Expand All @@ -404,11 +478,26 @@ public ResponseEntity<ResultJson> deleteExtension(
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
try {
var namespace = repositories.findNamespace(namespaceName);
if (namespace == null) {
var json = NamespaceDetailsJson
.error("Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName));
return new ResponseEntity<>(json, HttpStatus.NOT_FOUND);
}

// Authorize before touching the extension: only namespace members may delete.
// Owners may delete any version; non-owner members are restricted to versions
// they published themselves (enforced via restrictedToUser).
var isOwner = repositories.isNamespaceOwner(user, namespace);
if (!isOwner && !repositories.hasMembership(user, namespace)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}

var targets = CollectionUtil.toArray(
targetVersions,
TargetPlatformVersionJson::toTargetPlatformVersion,
TargetPlatformVersion[]::new);
var result = extensions.deleteUserExtension(user, namespaceName, extensionName, targets);
var result = extensions.deleteExtension(user, !isOwner, namespaceName, extensionName, targets);
return ResponseEntity.ok(result);
} catch (NotFoundException exc) {
var json = NamespaceDetailsJson
Expand All @@ -433,7 +522,8 @@ public List<NamespaceJson> getOwnNamespaces() {
var namespace = membership.getNamespace();
var extensions = new LinkedHashMap<String, String>();
var serverUrl = UrlUtil.getBaseUrl();
repositories.findActiveExtensionsForUrls(namespace).forEach(extension -> {
// return all extension of the namespace, include deleted ones
repositories.findExtensionsForUrls(namespace).forEach(extension -> {
String url = createApiUrl(serverUrl, "api", namespace.getName(), extension.getName());
extensions.put(extension.getName(), url);
});
Expand Down
Loading