Skip to content
Draft
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
4 changes: 2 additions & 2 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ dependencies {
implementation "org.springframework.boot:spring-boot-starter-cache"
implementation "org.springframework.boot:spring-boot-starter-mail"
implementation "org.springframework.boot:spring-boot-starter-zipkin"
implementation "org.springframework.boot:spring-boot-security-oauth2-client"
implementation "org.springframework.boot:spring-boot-starter-session-jdbc"
implementation "org.springframework.boot:spring-boot-starter-restclient"

implementation "org.springframework.security:spring-security-oauth2-client"
implementation "org.springframework.boot:spring-boot-security-oauth2-client"
implementation "org.springframework.security:spring-security-oauth2-jose"

implementation "org.springframework.boot:spring-boot-thymeleaf"
Expand Down
196 changes: 196 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/TrustedPublishingAPI.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/
package org.eclipse.openvsx;

import java.util.Objects;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import org.eclipse.openvsx.entities.TrustedPublisher;
import org.eclipse.openvsx.json.AccessTokenJson;
import org.eclipse.openvsx.json.ResultJson;
import org.eclipse.openvsx.json.TrustedPublisherJson;
import org.eclipse.openvsx.json.TrustedPublisherListJson;
import org.eclipse.openvsx.json.TrustedPublisherProviderJson;
import org.eclipse.openvsx.json.TrustedPublisherProviderListJson;
import org.eclipse.openvsx.json.TrustedPublisherTokenRequestJson;
import org.eclipse.openvsx.settings.MutatingOperation;
import org.eclipse.openvsx.trustedpublishing.TrustedPublishingService;
import org.eclipse.openvsx.util.ErrorResultException;
import org.eclipse.openvsx.util.NotFoundException;

@RestController
public class TrustedPublishingAPI {

private final UserService users;
private final TrustedPublishingService trustedPublishing;

public TrustedPublishingAPI(UserService users, TrustedPublishingService trustedPublishing) {
this.users = users;
this.trustedPublishing = trustedPublishing;
}

@PostMapping(
path = "/user/namespace/{namespace}/trusted-publishing/create",
produces = MediaType.APPLICATION_JSON_VALUE
)
@MutatingOperation
public ResponseEntity<TrustedPublisherJson> registerTrustedPublisher(
@PathVariable String namespace,
@RequestBody TrustedPublisherJson request
) {
var user = users.findLoggedInUser();
if (user == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
if (!StringUtils.hasText(request.getProvider())
|| !StringUtils.hasText(request.getNamespace()) || !StringUtils.hasText(request.getExtension())
|| request.getRegistration() == null || request.getRegistration().isEmpty()) {
var json = TrustedPublisherJson
.error("The fields provider, namespace, extension and registration are mandatory.");
return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST);
}
if (!Objects.equals(namespace, request.getNamespace())) {
var json = TrustedPublisherJson.error("The namespace in the path and in the request body must match.");
return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST);
}

try {
var publisher = trustedPublishing.registerTrustedPublisher(
user,
request.getNamespace(),
request.getExtension(),
request.getProvider(),
request.getRegistration());
return new ResponseEntity<>(publisher.toJson(), HttpStatus.CREATED);
} catch (NotFoundException exc) {
var json = TrustedPublisherJson.error("Namespace not found: " + namespace);
return new ResponseEntity<>(json, HttpStatus.NOT_FOUND);
} catch (ErrorResultException exc) {
return exc.toResponseEntity(TrustedPublisherJson.class);
}
}

@GetMapping(
path = "/user/namespace/{namespace}/trusted-publishing",
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<TrustedPublisherListJson> getTrustedPublishers(@PathVariable String namespace) {
var user = users.findLoggedInUser();
if (user == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}

try {
var json = new TrustedPublisherListJson();
json.setTrustedPublishers(
trustedPublishing.getTrustedPublishers(user, namespace).stream()
.map(TrustedPublisher::toJson)
.toList());
return ResponseEntity.ok(json);
} catch (NotFoundException exc) {
var json = TrustedPublisherListJson.error("Namespace not found: " + namespace);
return new ResponseEntity<>(json, HttpStatus.NOT_FOUND);
} catch (ErrorResultException exc) {
return exc.toResponseEntity(TrustedPublisherListJson.class);
}
}

@PostMapping(
path = "/user/namespace/{namespace}/trusted-publishing/delete/{id}",
produces = MediaType.APPLICATION_JSON_VALUE
)
@MutatingOperation
public ResponseEntity<ResultJson> deleteTrustedPublisher(@PathVariable String namespace, @PathVariable long id) {
var user = users.findLoggedInUser();
if (user == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}

try {
return ResponseEntity.ok(trustedPublishing.deleteTrustedPublisher(user, namespace, id));
} catch (NotFoundException exc) {
return new ResponseEntity<>(ResultJson.error("Trusted publisher does not exist."), HttpStatus.NOT_FOUND);
} catch (ErrorResultException exc) {
return exc.toResponseEntity();
}
}

@GetMapping(
path = "/user/namespace/{namespace}/trusted-publishing/providers",
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<TrustedPublisherProviderListJson> getTrustedPublisherProviders(
@PathVariable String namespace
) {
var user = users.findLoggedInUser();
if (user == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}

try {
var json = new TrustedPublisherProviderListJson();
json.setTrustedPublisherProviders(
trustedPublishing.getTrustedPublisherProviders(user, namespace).values().stream()
.map(p -> {
var providerJson = new TrustedPublisherProviderJson();
providerJson.setId(p.getProviderId());
providerJson.setName(p.getProviderName());
providerJson.setUrl(p.getProviderUrl());
providerJson.setRegistrationInputs(p.getRegistrationInputs());
return providerJson;
})
.toList());
return ResponseEntity.ok(json);
} catch (NotFoundException exc) {
var json = TrustedPublisherProviderListJson.error("Namespace not found: " + namespace);
return new ResponseEntity<>(json, HttpStatus.NOT_FOUND);
} catch (ErrorResultException exc) {
return exc.toResponseEntity(TrustedPublisherProviderListJson.class);
}
}

/**
* Exchanges a valid OIDC ID token, issued by a trusted publishing provider and matching a registered
* trusted publisher, for a short-lived access token to publish with.
*/
@PostMapping(
path = "/api/-/trusted-publishing/token",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
@MutatingOperation
public ResponseEntity<AccessTokenJson> requestPublishToken(@RequestBody TrustedPublisherTokenRequestJson request) {
if (!StringUtils.hasText(request.getNamespace()) || !StringUtils.hasText(request.getToken())) {
var json = AccessTokenJson.error("The fields namespace and token are mandatory.");
return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST);
}

try {
var json = trustedPublishing
.requestPublishToken(request.getNamespace(), request.getExtension(), request.getToken());
return new ResponseEntity<>(json, HttpStatus.CREATED);
} catch (ErrorResultException exc) {
return exc.toResponseEntity(AccessTokenJson.class);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public NamespaceJson rewriteUrls(NamespaceJson json) {
if (!StringUtils.isEmpty(json.getRoleUrl())) {
json.setRoleUrl(rewriteUrl(json.getRoleUrl()));
}
if (!StringUtils.isEmpty(json.getTrustedPublishingUrl())) {
json.setTrustedPublishingUrl(rewriteUrl(json.getTrustedPublishingUrl()));
}

return json;
}
Expand Down
2 changes: 2 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/UserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ public List<NamespaceJson> getOwnNamespaces() {
json.setMembersUrl(createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "members"));
json.setRoleUrl(createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "role"));
json.setDetailsUrl(createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "details"));
json.setTrustedPublishingUrl(
createApiUrl(serverUrl, "user", "namespace", namespace.getName(), "trusted-publishing"));
}

return json;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,22 @@ public AccessTokenService(

@Transactional
public AccessTokenJson createAccessToken(UserData user, String description) {
return createAccessToken(
user,
description,
config.isTokenExpiryEnabled()
? TimeUtil.getCurrentUTC().plus(config.getExpiration())
: null);
}

@Transactional
public AccessTokenJson createAccessToken(UserData user, String description, LocalDateTime expiresTimestamp) {
var token = new PersonalAccessToken();
token.setUser(user);
token.setValue(generateTokenValue());
token.setActive(true);

var createdAt = TimeUtil.getCurrentUTC();
token.setCreatedTimestamp(createdAt);

if (config.isTokenExpiryEnabled()) {
token.setExpiresTimestamp(createdAt.plus(config.getExpiration()));
}

token.setCreatedTimestamp(TimeUtil.getCurrentUTC());
token.setExpiresTimestamp(expiresTimestamp);
token.setDescription(description);
entityManager.persist(token);
var json = token.toAccessTokenJson();
Expand Down
2 changes: 2 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,8 @@ public ResponseEntity<NamespaceJson> getNamespace(
var adminNamespaceUrl = createAdminNamespaceUrl(namespace);
namespace.setMembersUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "members"));
namespace.setRoleUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "change-member"));
// TODO: decide do we have admin API for this
// namespace.setTrustedPublishingUrl(UrlUtil.createApiUrl(adminNamespaceUrl, "trusted-publishing"));
return ResponseEntity.ok(namespace);
} catch (NotFoundException exc) {
var json = NamespaceJson.error("Namespace not found: " + namespaceName);
Expand Down
Loading