From fbc35fa45815cb2c3d91ec159ef28949196fc80b Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 12 May 2026 11:06:57 +0200 Subject: [PATCH 01/14] add FeatureFlagService and aspect for read-only mode, add MutatingOperation annotation to indicate endpoint that modify the DB, add annotation to all relevant endpoints --- .../java/org/eclipse/openvsx/RegistryAPI.java | 7 +++ .../java/org/eclipse/openvsx/UserAPI.java | 7 +++ .../org/eclipse/openvsx/admin/AdminAPI.java | 11 +++++ .../openvsx/admin/FileDecisionAPI.java | 3 ++ .../eclipse/openvsx/admin/RateLimitAPI.java | 11 +++++ .../org/eclipse/openvsx/admin/ScanAPI.java | 3 ++ .../featureflag/FeatureFlagService.java | 26 +++++++++++ .../featureflag/MutatingOperation.java | 20 ++++++++ .../featureflag/ReadOnlyEndpointAspect.java | 46 +++++++++++++++++++ 9 files changed, 134 insertions(+) create mode 100644 server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java create mode 100644 server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index b272a706b..e812d1c2f 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java @@ -20,6 +20,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.SemanticVersion; +import org.eclipse.openvsx.featureflag.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.search.ISearchService; import org.eclipse.openvsx.search.SortBy; @@ -1095,6 +1096,7 @@ public ResponseEntity postQuery( produces = MediaType.APPLICATION_JSON_VALUE ) @Operation(summary = "Create a namespace") + @MutatingOperation @ApiResponse( responseCode = "201", description = "Successfully created the namespace", @@ -1155,6 +1157,7 @@ public ResponseEntity createNamespace( required = true ) ) + @MutatingOperation @ApiResponse( responseCode = "201", description = "Successfully created the namespace", @@ -1222,6 +1225,7 @@ public ResponseEntity createNamespace( required = true ) ) + @MutatingOperation @ApiResponse( responseCode = "201", description = "Successfully published the extension", @@ -1269,6 +1273,7 @@ public ResponseEntity publish( required = true ) ) + @MutatingOperation @ApiResponse( responseCode = "201", description = "Successfully published the extension", @@ -1315,6 +1320,7 @@ public ResponseEntity publish(InputStream content) { produces = MediaType.APPLICATION_JSON_VALUE ) @Operation(hidden = true) + @MutatingOperation public ResponseEntity postReview( @RequestBody(required = false) ReviewJson review, @PathVariable String namespace, @@ -1349,6 +1355,7 @@ public ResponseEntity postReview( produces = MediaType.APPLICATION_JSON_VALUE ) @Operation(hidden = true) + @MutatingOperation public ResponseEntity deleteReview(@PathVariable String namespace, @PathVariable String extension) { var json = local.deleteReview(namespace, extension); if (json.getError() == null) { diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index af26ac5fe..82aa5bf1f 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -13,6 +13,7 @@ import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.featureflag.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; @@ -163,6 +164,7 @@ public List getAccessTokens() { path = "/user/token/create", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity createAccessToken(@RequestParam(required = false) String description) { if (description != null && description.length() > TOKEN_DESCRIPTION_SIZE) { var json = AccessTokenJson.error("The description must not be longer than " + TOKEN_DESCRIPTION_SIZE + " characters."); @@ -180,6 +182,7 @@ public ResponseEntity createAccessToken(@RequestParam(required path = "/user/token/delete/{id}", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deleteAccessToken(@PathVariable long id) { var user = users.findLoggedInUser(); if (user == null) { @@ -345,6 +348,7 @@ public ResponseEntity getOwnExtension(@PathVariable String namesp path = "/user/extension/{namespaceName}/{extensionName}/delete", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deleteExtension( @PathVariable String namespaceName, @PathVariable String extensionName, @@ -400,6 +404,7 @@ public List getOwnNamespaces() { path = "/user/namespace/{namespace}/details", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity updateNamespaceDetails(@RequestBody NamespaceDetailsJson details) { var user = users.findLoggedInUser(); if (user == null) { @@ -423,6 +428,7 @@ public ResponseEntity updateNamespaceDetails(@RequestBody NamespaceD produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) + @MutatingOperation public ResponseEntity updateNamespaceDetailsLogo( @PathVariable String namespace, @RequestParam MultipartFile file @@ -464,6 +470,7 @@ public ResponseEntity getNamespaceMembers(@PathVari path = "/user/namespace/{namespace}/role", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity setNamespaceMember(@PathVariable String namespace, @RequestParam String user, @RequestParam String role, @RequestParam(required = false) String provider) { var requestingUser = users.findLoggedInUser(); 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 1ea05f8a0..1cf87db31 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -21,6 +21,7 @@ import org.eclipse.openvsx.entities.AdminStatistics; import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersistedLog; +import org.eclipse.openvsx.featureflag.MutatingOperation; import org.eclipse.openvsx.json.AdminStatisticsJson; import org.eclipse.openvsx.json.ChangeNamespaceJson; import org.eclipse.openvsx.json.ExtensionJson; @@ -290,6 +291,7 @@ public ResponseEntity getExtension(@PathVariable String namespace ) @CrossOrigin @Operation(summary = "Delete an extension or one or multiple extension versions") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format", @@ -324,6 +326,7 @@ public ResponseEntity deleteExtension( path = "/admin/extension/{namespaceName}/{extensionName}/delete", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deleteExtension( @PathVariable String namespaceName, @PathVariable String extensionName, @@ -344,6 +347,7 @@ public ResponseEntity deleteExtension( ) @CrossOrigin @Operation(summary = "Delete a review for an extension by a user") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format", @@ -405,6 +409,7 @@ private String createAdminNamespaceUrl(NamespaceJson namespace) { consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity createNamespace(@RequestBody NamespaceJson namespace) { try { admins.checkAdminUser(); @@ -422,6 +427,7 @@ public ResponseEntity createNamespace(@RequestBody NamespaceJson nam path = "/admin/namespace/{namespaceName}" ) @Operation(summary = "Delete a namespace") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format" @@ -453,6 +459,7 @@ public ResponseEntity deleteNamespace(@PathVariable String namespace consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity changeNamespace(@RequestBody ChangeNamespaceJson json) { try { admins.checkAdminUser(); @@ -510,6 +517,7 @@ public ResponseEntity getNamespaceMembers(@PathVari ) @CrossOrigin @Operation(summary = "Edit a member of a namespace") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format", @@ -545,6 +553,7 @@ public ResponseEntity editNamespaceMember( path = "/admin/namespace/{namespaceName}/change-member", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity editNamespaceMember( @PathVariable String namespaceName, @RequestParam("user") String userName, @@ -578,6 +587,7 @@ public ResponseEntity getUserPublishInfo(@PathVariable Stri path = "/admin/publisher/{provider}/{loginName}/revoke", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity revokePublisherContributions(@PathVariable String loginName, @PathVariable String provider) { try { var adminUser = admins.checkAdminUser(); @@ -592,6 +602,7 @@ public ResponseEntity revokePublisherContributions(@PathVariable Str path = "/admin/publisher/{provider}/{loginName}/tokens/revoke", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity revokePublisherTokens(@PathVariable String loginName, @PathVariable String provider) { try { var adminUser = admins.checkAdminUser(); diff --git a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java index 864e5b25a..024e94ea9 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.eclipse.openvsx.entities.FileDecision; +import org.eclipse.openvsx.featureflag.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; @@ -180,6 +181,7 @@ public ResponseEntity getFileDecision( ) @CrossOrigin @Operation(summary = "Create or update file decisions") + @MutatingOperation @ApiResponse( responseCode = "200", description = "Decisions processed successfully", @@ -263,6 +265,7 @@ public ResponseEntity makeFileDecisions( ) @CrossOrigin @Operation(summary = "Remove file decisions") + @MutatingOperation @ApiResponse( responseCode = "200", description = "Deletions processed successfully", diff --git a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java index f9bdb2ce3..c21f79989 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -13,6 +13,7 @@ package org.eclipse.openvsx.admin; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.featureflag.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; @@ -81,6 +82,7 @@ public ResponseEntity getTiers() { consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity createTier(@RequestBody TierJson tier) { try { var adminUser = admins.checkAdminUser(); @@ -122,6 +124,7 @@ public ResponseEntity createTier(@RequestBody TierJson tier) { consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity updateTier(@PathVariable String name, @RequestBody TierJson tier) { try { var adminUser = admins.checkAdminUser(); @@ -163,6 +166,7 @@ public ResponseEntity updateTier(@PathVariable String name, @RequestBo path = "/tiers/{name}", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deleteTier(@PathVariable String name) { try { var adminUser = admins.checkAdminUser(); @@ -261,6 +265,7 @@ public ResponseEntity getCustomer(@PathVariable String name) { consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity createCustomer(@RequestBody CustomerJson customerJson) { try { var adminUser = admins.checkAdminUser(); @@ -306,6 +311,7 @@ public ResponseEntity createCustomer(@RequestBody CustomerJson cus consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity updateCustomer(@PathVariable String name, @RequestBody CustomerJson customer) { try { var adminUser = admins.checkAdminUser(); @@ -373,6 +379,7 @@ public ResponseEntity getCustomerMembers(@PathVariab path = "/customers/{name}/add-member", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity addCustomerMember( @PathVariable String name, @RequestParam("user") String userName, @@ -396,6 +403,7 @@ public ResponseEntity addCustomerMember( path = "/customers/{name}/remove-member", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity removeCustomerMember( @PathVariable String name, @RequestParam("user") String userName, @@ -419,6 +427,7 @@ public ResponseEntity removeCustomerMember( path = "/customers/{name}", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deleteCustomer(@PathVariable String name) { try { var adminUser = admins.checkAdminUser(); @@ -511,6 +520,7 @@ public ResponseEntity> getRateLimitTokens(@PathVariable path = "/customers/{name}/tokens", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity createRateLimitToken( @PathVariable String name, @RequestParam(required = false) String description @@ -541,6 +551,7 @@ public ResponseEntity createRateLimitToken( path = "/customers/{name}/tokens/{id}", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deactivateRateLimitToken(@PathVariable String name, @PathVariable long id) { try { admins.checkAdminUser(); diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java index 0021ab641..be1a07fbf 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.featureflag.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.storage.StorageUtilService; @@ -584,6 +585,7 @@ public ResponseEntity getScan( ) @CrossOrigin @Operation(summary = "Retry all failed scanner jobs for a scan") + @MutatingOperation @ApiResponse( responseCode = "200", description = "Failed jobs re-queued; returns the updated scan in SCANNING state", @@ -641,6 +643,7 @@ public ResponseEntity retryFailedScannerJobs( ) @CrossOrigin @Operation(summary = "Make security decisions for quarantined scans") + @MutatingOperation @ApiResponse( responseCode = "200", description = "Decisions processed successfully", diff --git a/server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java b/server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java new file mode 100644 index 000000000..1937106c5 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java @@ -0,0 +1,26 @@ +/****************************************************************************** + * 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.featureflag; + +import org.springframework.stereotype.Service; + +@Service +public class FeatureFlagService { + + public FeatureFlagService() {} + + public boolean isRegistryReadOnly() { + // TODO: implement this + return true; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java b/server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java new file mode 100644 index 000000000..86c7b409a --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java @@ -0,0 +1,20 @@ +/****************************************************************************** + * 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.featureflag; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MutatingOperation {} diff --git a/server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java b/server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java new file mode 100644 index 000000000..4464c98dd --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java @@ -0,0 +1,46 @@ +/****************************************************************************** + * 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.featureflag; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.eclipse.openvsx.json.ResultJson; +import org.springframework.stereotype.Component; +import org.springframework.http.ResponseEntity; + +@Aspect +@Component +public class ReadOnlyEndpointAspect { + + private final FeatureFlagService featureFlagService; + + public ReadOnlyEndpointAspect(FeatureFlagService featureFlagService) { + this.featureFlagService = featureFlagService; + } + + @Around("(execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.RegistryAPI.*(..)) ||" + + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.AdminAPI.*(..)) ||" + + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.FileDecisionAPI.*(..)) ||" + + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.RateLimitAPI.*(..)) ||" + + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.ScanAPI.*(..)) ||" + + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.UserAPI.*(..))) &&" + + "@annotation(MutatingOperation)") + public Object handleMutatingEndpoint(ProceedingJoinPoint joinPoint) throws Throwable { + if (featureFlagService.isRegistryReadOnly()) { + return ResponseEntity.status(409).body(ResultJson.error("Registry is in read-only mode.")); + } else { + return joinPoint.proceed(); + } + } +} From 43ab4f771d52a2420b2b6231c0255a73bbd1c873 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 12 May 2026 11:07:30 +0200 Subject: [PATCH 02/14] fix warning with existing aspect and add fixme for broken aspect --- .../eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java | 3 ++- .../eclipse/openvsx/mirror/aop/RepositoryServiceAspect.java | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java b/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java index 12b2c9242..781898c01 100644 --- a/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java +++ b/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java @@ -9,6 +9,7 @@ * ****************************************************************************** */ package org.eclipse.openvsx.mirror.aop; +import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -20,7 +21,7 @@ public class DownloadCountServiceAspect { @Around("execution(* org.eclipse.openvsx.storage.log.*DownloadCountService.isEnabled(..))") - public Object isEnabled() throws Throwable { + public Object isEnabled(ProceedingJoinPoint ignoredPjp) throws Throwable { return false; } } diff --git a/server/src/main/java/org/eclipse/openvsx/mirror/aop/RepositoryServiceAspect.java b/server/src/main/java/org/eclipse/openvsx/mirror/aop/RepositoryServiceAspect.java index d76461a4f..873bc09e3 100644 --- a/server/src/main/java/org/eclipse/openvsx/mirror/aop/RepositoryServiceAspect.java +++ b/server/src/main/java/org/eclipse/openvsx/mirror/aop/RepositoryServiceAspect.java @@ -29,6 +29,9 @@ public RepositoryServiceAspect(StorageUtilService storageUtil) { this.storageUtil = storageUtil; } + // FIXME: In PublishExtensionVersionHandler.mirror(TempFile extensionFile, String signatureName) + // it is mentioned that FileResources should not be stored but generated on the fly + // however they are persisted in the DB and this pointcut points to a non-existing method @Around("execution(* org.eclipse.openvsx.repositories.RepositoryService.findFileByTypeAndName(..))") public Object findFileByTypeAndName(ProceedingJoinPoint joinPoint) throws Throwable { var args = joinPoint.getArgs(); From fb3d988403e5731a594e26a70fa4c216a2162f85 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 12 May 2026 20:39:46 +0200 Subject: [PATCH 03/14] refactor to SettingsService in package settings --- .../java/org/eclipse/openvsx/RegistryAPI.java | 2 +- .../main/java/org/eclipse/openvsx/UserAPI.java | 2 +- .../org/eclipse/openvsx/adapter/VSCodeAPI.java | 2 -- .../org/eclipse/openvsx/admin/AdminAPI.java | 2 +- .../eclipse/openvsx/admin/FileDecisionAPI.java | 2 +- .../eclipse/openvsx/admin/RateLimitAPI.java | 2 +- .../org/eclipse/openvsx/admin/ScanAPI.java | 2 +- .../MutatingOperation.java | 5 ++++- .../ReadOnlyEndpointAspect.java | 18 +++++++----------- .../SettingsService.java} | 6 +++--- 10 files changed, 20 insertions(+), 23 deletions(-) rename server/src/main/java/org/eclipse/openvsx/{featureflag => settings}/MutatingOperation.java (86%) rename server/src/main/java/org/eclipse/openvsx/{featureflag => settings}/ReadOnlyEndpointAspect.java (64%) rename server/src/main/java/org/eclipse/openvsx/{featureflag/FeatureFlagService.java => settings/SettingsService.java} (86%) diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index e812d1c2f..9bb21ab19 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java @@ -20,7 +20,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; import org.eclipse.openvsx.entities.SemanticVersion; -import org.eclipse.openvsx.featureflag.MutatingOperation; +import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.search.ISearchService; import org.eclipse.openvsx.search.SortBy; diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 82aa5bf1f..febb05d2b 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -13,7 +13,7 @@ import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.entities.*; -import org.eclipse.openvsx.featureflag.MutatingOperation; +import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; diff --git a/server/src/main/java/org/eclipse/openvsx/adapter/VSCodeAPI.java b/server/src/main/java/org/eclipse/openvsx/adapter/VSCodeAPI.java index 71cbd8a00..20a4e5c5c 100644 --- a/server/src/main/java/org/eclipse/openvsx/adapter/VSCodeAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/adapter/VSCodeAPI.java @@ -35,10 +35,8 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.util.List; -import java.util.Optional; import java.util.concurrent.TimeUnit; -import static org.eclipse.openvsx.adapter.ExtensionQueryParam.*; import static org.eclipse.openvsx.adapter.ExtensionQueryResult.ExtensionFile.*; import static org.eclipse.openvsx.util.TargetPlatform.*; 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 1cf87db31..d0b932340 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -21,7 +21,7 @@ import org.eclipse.openvsx.entities.AdminStatistics; import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersistedLog; -import org.eclipse.openvsx.featureflag.MutatingOperation; +import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.json.AdminStatisticsJson; import org.eclipse.openvsx.json.ChangeNamespaceJson; import org.eclipse.openvsx.json.ExtensionJson; diff --git a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java index 024e94ea9..4139c559e 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java @@ -18,7 +18,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.eclipse.openvsx.entities.FileDecision; -import org.eclipse.openvsx.featureflag.MutatingOperation; +import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; diff --git a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java index c21f79989..e1148a9de 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -13,7 +13,7 @@ package org.eclipse.openvsx.admin; import org.eclipse.openvsx.entities.*; -import org.eclipse.openvsx.featureflag.MutatingOperation; +import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; diff --git a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java index be1a07fbf..d83acea92 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java @@ -22,7 +22,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; import org.eclipse.openvsx.entities.*; -import org.eclipse.openvsx.featureflag.MutatingOperation; +import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.storage.StorageUtilService; diff --git a/server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java b/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java similarity index 86% rename from server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java rename to server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java index 86c7b409a..133b38c4b 100644 --- a/server/src/main/java/org/eclipse/openvsx/featureflag/MutatingOperation.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java @@ -10,10 +10,13 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.featureflag; +package org.eclipse.openvsx.settings; import java.lang.annotation.*; +/** + * A marker annotation to indicate an operation that mutates the DB. + */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented diff --git a/server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java similarity index 64% rename from server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java rename to server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java index 4464c98dd..bca5894b5 100644 --- a/server/src/main/java/org/eclipse/openvsx/featureflag/ReadOnlyEndpointAspect.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.featureflag; +package org.eclipse.openvsx.settings; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -23,21 +23,17 @@ @Component public class ReadOnlyEndpointAspect { - private final FeatureFlagService featureFlagService; + private final SettingsService settingsService; - public ReadOnlyEndpointAspect(FeatureFlagService featureFlagService) { - this.featureFlagService = featureFlagService; + public ReadOnlyEndpointAspect(SettingsService settingsService) { + this.settingsService = settingsService; } @Around("(execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.RegistryAPI.*(..)) ||" + - " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.AdminAPI.*(..)) ||" + - " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.FileDecisionAPI.*(..)) ||" + - " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.RateLimitAPI.*(..)) ||" + - " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.ScanAPI.*(..)) ||" + - " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.UserAPI.*(..))) &&" + - "@annotation(MutatingOperation)") + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.UserAPI.*(..)) ||" + + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.*API.*(..))) && @annotation(MutatingOperation)") public Object handleMutatingEndpoint(ProceedingJoinPoint joinPoint) throws Throwable { - if (featureFlagService.isRegistryReadOnly()) { + if (settingsService.isRegistryReadOnly()) { return ResponseEntity.status(409).body(ResultJson.error("Registry is in read-only mode.")); } else { return joinPoint.proceed(); diff --git a/server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java similarity index 86% rename from server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java rename to server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java index 1937106c5..3397667ad 100644 --- a/server/src/main/java/org/eclipse/openvsx/featureflag/FeatureFlagService.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java @@ -10,14 +10,14 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.featureflag; +package org.eclipse.openvsx.settings; import org.springframework.stereotype.Service; @Service -public class FeatureFlagService { +public class SettingsService { - public FeatureFlagService() {} + public SettingsService() {} public boolean isRegistryReadOnly() { // TODO: implement this From c36a67f31bfd006567afc687fa4af0fa6fbed372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Tue, 12 May 2026 20:40:57 +0200 Subject: [PATCH 04/14] feat(admin): adding settings page (#1818) --- webui/src/extension-registry-service.ts | 31 +++ webui/src/extension-registry-types.ts | 4 + .../admin-dashboard/admin-dashboard-routes.ts | 1 + .../pages/admin-dashboard/admin-dashboard.tsx | 4 + .../runtime-feature-flag-item.tsx | 69 +++++++ .../admin-dashboard/runtime-feature-flags.tsx | 179 ++++++++++++++++++ 6 files changed, 288 insertions(+) create mode 100644 webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx create mode 100644 webui/src/pages/admin-dashboard/runtime-feature-flags.tsx diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index a01e3191f..a5140a07b 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -16,6 +16,7 @@ import { FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, CustomerMembershipList, RateLimitToken, + RuntimeFeatureFlags, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -538,6 +539,8 @@ export interface AdminService { getCustomerRateLimitTokens(abortController: AbortController, customerName: string): Promise>; createCustomerRateLimitToken(abortController: AbortController, customerName: string, description: string): Promise>; deleteCustomerRateLimitToken(abortController: AbortController, customerName: string, tokenId: number): Promise>; + getRuntimeFeatureFlags(abortController: AbortController): Promise>; + updateRuntimeFeatureFlags(abortController: AbortController, featureFlags: RuntimeFeatureFlags): Promise>; } export interface AdminServiceConstructor { @@ -1149,6 +1152,34 @@ export class AdminServiceImpl implements AdminService { headers }, false); } + + async getRuntimeFeatureFlags(abortController: AbortController): Promise> { + return new Promise((resolve, reject) => { + setTimeout(() => resolve({ + readOnlyMode: false + }), 1000); + }); + } + + async updateRuntimeFeatureFlags(abortController: AbortController, featureFlags: RuntimeFeatureFlags): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + + return sendRequest({ + abortController, + method: 'PUT', + payload: featureFlags, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'feature-flags']), + headers + }, false); + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index cc2d5fc4f..b6f864c83 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -500,3 +500,7 @@ export interface LogPageableList { totalPages: number; }; } + +export interface RuntimeFeatureFlags { + readOnlyMode: boolean; +} diff --git a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts index 4493ca27c..ceebf4611 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts +++ b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts @@ -20,5 +20,6 @@ export namespace AdminDashboardRoutes { export const TIERS = createRoute([ROOT, 'tiers']); export const CUSTOMERS = createRoute([ROOT, 'customers']); export const USAGE_STATS = createRoute([ROOT, 'usage']); + export const RUNTIME_FEATURE_FLAGS = createRoute([ROOT, 'settings']); export const LOGS = createRoute([ROOT, 'logs']); } diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 9344b20cd..815b52022 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -26,6 +26,7 @@ import HistoryIcon from '@mui/icons-material/History'; import PeopleIcon from '@mui/icons-material/People'; import PersonIcon from '@mui/icons-material/Person'; import SecurityIcon from '@mui/icons-material/Security'; +import SettingsIcon from '@mui/icons-material/Settings'; import SpeedIcon from '@mui/icons-material/Speed'; import StarIcon from '@mui/icons-material/Star'; import { LoginComponent } from "../../default/login"; @@ -42,6 +43,7 @@ import { Tiers } from './tiers/tiers'; import { Customers } from './customers/customers'; import { CustomerDetails } from './customers/customer-details'; import { Logs } from './logs/logs'; +import { RuntimeFeatureFlagsPage } from './runtime-feature-flags'; import { Welcome } from './welcome'; const ExtensionAdmin = lazy(() => import('./extension-admin').then(m => ({ default: m.ExtensionAdmin }))); @@ -61,6 +63,7 @@ const navConfig: NavEntry[] = [ { path: AdminDashboardRoutes.USAGE_STATS, name: 'Usage Stats', icon: , description: 'Show usage stats for customers' }, ], }, + { path: AdminDashboardRoutes.RUNTIME_FEATURE_FLAGS, name: 'Settings', icon: , description: 'Manage runtime feature flags for the registry' }, { path: AdminDashboardRoutes.LOGS, name: 'Logs', icon: , description: 'Browse admin activity logs' }, ]; @@ -136,6 +139,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx b/webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx new file mode 100644 index 000000000..1aef504c6 --- /dev/null +++ b/webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx @@ -0,0 +1,69 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ + +import { ChangeEvent, FC } from 'react'; +import { Box, Skeleton, Switch, Typography } from '@mui/material'; + +export interface RuntimeFeatureFlagItemProps { + title: string; + description: string; + checked: boolean; + disabled?: boolean; + loading?: boolean; + onChange: (event: ChangeEvent, checked: boolean) => void; +} + +export const RuntimeFeatureFlagItem: FC = ({ + title, + description, + checked, + disabled, + loading, + onChange, +}) => { + return ( + + + + {title} + + + {description} + + + + {(loading) ? ( + + ) : ( + + )} + + + {description} + + + ); + }; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/runtime-feature-flags.tsx b/webui/src/pages/admin-dashboard/runtime-feature-flags.tsx new file mode 100644 index 000000000..541e42380 --- /dev/null +++ b/webui/src/pages/admin-dashboard/runtime-feature-flags.tsx @@ -0,0 +1,179 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ + +import { ChangeEvent, FC, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { + Alert, + Box, + Divider, + Paper, + Stack, + Typography, +} from '@mui/material'; +import { MainContext } from '../../context'; +import type { RuntimeFeatureFlags } from '../../extension-registry-types'; +import { handleError } from '../../utils'; +import { RuntimeFeatureFlagItem } from './runtime-feature-flag-item'; + +interface NotificationState { + id: string; + message: string; + severity: 'success' | 'error'; + timeout: ReturnType; +} + +const NOTIFICATION_TIMEOUT = 5000; + +const FEATURE_FLAGS: Record = { + readOnlyMode: { + title: 'Read-only mode', + description: 'Blocks write operations while keeping browsing, search, and downloads available.', + }, +}; + +export const RuntimeFeatureFlagsPage: FC = () => { + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [featureFlags, setFeatureFlags] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + return () => abortController.current.abort(); + }, []); + + const loadRuntimeFeatureFlags = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getRuntimeFeatureFlags(abortController.current); + setFeatureFlags(data); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service]); + + useEffect(() => { + loadRuntimeFeatureFlags(); + }, [loadRuntimeFeatureFlags]); + + useEffect(() => () => { + notifications.forEach(n => clearTimeout(n.timeout)); + }, []); + + const addNotification = useCallback((notification: Pick) => { + const id = crypto.randomUUID(); + const timeout = setTimeout(() => { + setNotifications(current => current.filter(n => n.id !== id)); + }, NOTIFICATION_TIMEOUT); + setNotifications(current => [...current, { ...notification, id, timeout }]); + }, []); + + const handleNotificationClose = (id: string) => { + setNotifications(current => { + const notification = current.find(n => n.id === id); + if (notification) clearTimeout(notification.timeout); + return current.filter(n => n.id !== id); + }); + }; + + const handleFlagChange = useCallback((key: keyof RuntimeFeatureFlags) => async (_event: ChangeEvent, checked: boolean) => { + if (!featureFlags || saving) return; + + const previousFeatureFlags = featureFlags; + const nextFeatureFlags: RuntimeFeatureFlags = { ...featureFlags, [key]: checked }; + + setFeatureFlags(nextFeatureFlags); + setSaving(true); + setError(null); + + try { + const updatedFeatureFlags = await service.admin.updateRuntimeFeatureFlags(abortController.current, nextFeatureFlags); + setFeatureFlags(updatedFeatureFlags); + addNotification({ severity: 'success', message: 'Runtime feature flags saved.' }); + } catch (err) { + setFeatureFlags(previousFeatureFlags); + addNotification({ + severity: 'error', + message: `Failed to save runtime feature flags. ${handleError(err as Error)}`, + }); + } finally { + setSaving(false); + } + }, [featureFlags, saving, service, addNotification]); + + return ( + <> + + + + Settings + + + Manage runtime feature flags that apply across the registry. + + + + {error && ( + setError(null)}> + {error} + + )} + + + {(Object.entries(FEATURE_FLAGS) as [keyof RuntimeFeatureFlags, { title: string; description: string }][]).map(([key, flag]) => ( + + ))} + + + + {notifications.length > 0 && ( + theme.zIndex.snackbar, + width: 'min(420px, calc(100vw - 32px))', + }} + > + {notifications.map(notification => ( + handleNotificationClose(notification.id)} + severity={notification.severity} + variant='filled' + sx={{ width: '100%' }} + > + {notification.message} + + ))} + + )} + + ); +}; \ No newline at end of file From 8c2d9a7d577a4500f57069456472f7e472307981 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 12 May 2026 21:49:17 +0200 Subject: [PATCH 05/14] add api endpoints, rename feature flag to settings --- .../org/eclipse/openvsx/admin/AdminAPI.java | 104 +++++++++++------- .../eclipse/openvsx/json/SettingsJson.java | 31 ++++++ .../settings/ReadOnlyEndpointAspect.java | 2 +- .../openvsx/settings/SettingsService.java | 18 ++- webui/src/extension-registry-service.ts | 24 ++-- webui/src/extension-registry-types.ts | 4 +- .../admin-dashboard/admin-dashboard-routes.ts | 2 +- .../pages/admin-dashboard/admin-dashboard.tsx | 4 +- .../runtime-feature-flag-item.tsx | 69 ------------ .../pages/admin-dashboard/settings-item.tsx | 70 ++++++++++++ ...runtime-feature-flags.tsx => settings.tsx} | 45 ++++---- 11 files changed, 220 insertions(+), 153 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/json/SettingsJson.java delete mode 100644 webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx create mode 100644 webui/src/pages/admin-dashboard/settings-item.tsx rename webui/src/pages/admin-dashboard/{runtime-feature-flags.tsx => settings.tsx} (75%) 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 d0b932340..1355099e3 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -21,19 +21,11 @@ import org.eclipse.openvsx.entities.AdminStatistics; import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersistedLog; +import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.settings.MutatingOperation; -import org.eclipse.openvsx.json.AdminStatisticsJson; -import org.eclipse.openvsx.json.ChangeNamespaceJson; -import org.eclipse.openvsx.json.ExtensionJson; -import org.eclipse.openvsx.json.NamespaceJson; -import org.eclipse.openvsx.json.NamespaceMembershipListJson; -import org.eclipse.openvsx.json.PersistedLogJson; -import org.eclipse.openvsx.json.ResultJson; -import org.eclipse.openvsx.json.StatsJson; -import org.eclipse.openvsx.json.TargetPlatformVersionJson; -import org.eclipse.openvsx.json.UserPublishInfoJson; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.settings.SettingsService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.NamingUtil; @@ -46,14 +38,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.DeleteMapping; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import io.swagger.v3.oas.annotations.Operation; @@ -63,6 +48,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; @RestController +@RequestMapping("/admin") @ApiResponse( responseCode = "403", description = "Administration role is required", @@ -72,6 +58,7 @@ public class AdminAPI { private final RepositoryService repositories; private final AdminService admins; + private final SettingsService settings; private final LogService logs; private final LocalRegistryService local; private final SearchUtilService search; @@ -79,19 +66,21 @@ public class AdminAPI { public AdminAPI( RepositoryService repositories, AdminService admins, + SettingsService settings, LogService logs, LocalRegistryService local, SearchUtilService search ) { this.repositories = repositories; this.admins = admins; + this.settings = settings; this.logs = logs; this.local = local; this.search = search; } @GetMapping( - path = "/admin/report", + path = "/report", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -123,7 +112,7 @@ public ResponseEntity getReportJson( } @GetMapping( - path = "/admin/report", + path = "/report", produces = "text/csv" ) @CrossOrigin @@ -147,7 +136,7 @@ private AdminStatistics getReport(String tokenValue, int year, int month) { } @GetMapping( - path = "/admin/stats", + path = "/stats", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity getStats() { @@ -165,7 +154,7 @@ public ResponseEntity getStats() { } @GetMapping( - path = "/admin/log", + path = "/log", produces = MediaType.TEXT_PLAIN_VALUE ) public String getLog(@RequestParam(name = "period", required = false) String periodString) { @@ -194,7 +183,7 @@ public String getLog(@RequestParam(name = "period", required = false) String per } @GetMapping( - path = "/admin/logs", + path = "/logs", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity> getLog( @@ -233,7 +222,7 @@ private String toString(PersistedLog log) { } @PostMapping( - path = "/admin/update-search-index", + path = "/update-search-index", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity updateSearchIndex() { @@ -251,7 +240,7 @@ public ResponseEntity updateSearchIndex() { } @GetMapping( - path = "/admin/extension/{namespaceName}/{extensionName}", + path = "/extension/{namespaceName}/{extensionName}", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity getExtension(@PathVariable String namespaceName, @@ -286,7 +275,7 @@ public ResponseEntity getExtension(@PathVariable String namespace } @PostMapping( - path = "/admin/api/extension/{namespaceName}/{extensionName}/delete", + path = "/api/extension/{namespaceName}/{extensionName}/delete", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -323,7 +312,7 @@ public ResponseEntity deleteExtension( } @PostMapping( - path = "/admin/extension/{namespaceName}/{extensionName}/delete", + path = "/extension/{namespaceName}/{extensionName}/delete", produces = MediaType.APPLICATION_JSON_VALUE ) @MutatingOperation @@ -342,7 +331,7 @@ public ResponseEntity deleteExtension( } @PostMapping( - path = "/admin/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", + path = "/extension/{namespace}/{extension}/review/{provider}/{loginName}/delete", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -380,7 +369,7 @@ public ResponseEntity deleteReview( } @GetMapping( - path = "/admin/namespace/{namespaceName}", + path = "/namespace/{namespaceName}", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity getNamespace(@PathVariable String namespaceName) { @@ -405,7 +394,7 @@ private String createAdminNamespaceUrl(NamespaceJson namespace) { } @PostMapping( - path = "/admin/create-namespace", + path = "/create-namespace", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) @@ -424,7 +413,7 @@ public ResponseEntity createNamespace(@RequestBody NamespaceJson nam } @DeleteMapping( - path = "/admin/namespace/{namespaceName}" + path = "/namespace/{namespaceName}" ) @Operation(summary = "Delete a namespace") @MutatingOperation @@ -455,7 +444,7 @@ public ResponseEntity deleteNamespace(@PathVariable String namespace } @PostMapping( - path = "/admin/change-namespace", + path = "/change-namespace", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) @@ -471,7 +460,7 @@ public ResponseEntity changeNamespace(@RequestBody ChangeNamespaceJs } @GetMapping( - path = "/admin/api/namespace/{namespaceName}/members", + path = "/api/namespace/{namespaceName}/members", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -496,7 +485,7 @@ public ResponseEntity getNamespaceMembers( } @GetMapping( - path = "/admin/namespace/{namespaceName}/members", + path = "/namespace/{namespaceName}/members", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity getNamespaceMembers(@PathVariable String namespaceName) { @@ -512,7 +501,7 @@ public ResponseEntity getNamespaceMembers(@PathVari } @PostMapping( - path = "/admin/api/namespace/{namespaceName}/change-member", + path = "/api/namespace/{namespaceName}/change-member", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @@ -550,7 +539,7 @@ public ResponseEntity editNamespaceMember( } @PostMapping( - path = "/admin/namespace/{namespaceName}/change-member", + path = "/namespace/{namespaceName}/change-member", produces = MediaType.APPLICATION_JSON_VALUE ) @MutatingOperation @@ -570,7 +559,7 @@ public ResponseEntity editNamespaceMember( } @GetMapping( - path = "/admin/publisher/{provider}/{loginName}", + path = "/publisher/{provider}/{loginName}", produces = MediaType.APPLICATION_JSON_VALUE ) public ResponseEntity getUserPublishInfo(@PathVariable String provider, @PathVariable String loginName) { @@ -584,7 +573,7 @@ public ResponseEntity getUserPublishInfo(@PathVariable Stri } @PostMapping( - path = "/admin/publisher/{provider}/{loginName}/revoke", + path = "/publisher/{provider}/{loginName}/revoke", produces = MediaType.APPLICATION_JSON_VALUE ) @MutatingOperation @@ -599,7 +588,7 @@ public ResponseEntity revokePublisherContributions(@PathVariable Str } @PostMapping( - path = "/admin/publisher/{provider}/{loginName}/tokens/revoke", + path = "/publisher/{provider}/{loginName}/tokens/revoke", produces = MediaType.APPLICATION_JSON_VALUE ) @MutatingOperation @@ -612,4 +601,39 @@ public ResponseEntity revokePublisherTokens(@PathVariable String log return exc.toResponseEntity(); } } -} \ No newline at end of file + + @GetMapping( + path = "/settings", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getSettings() { + try { + admins.checkAdminUser(); + return ResponseEntity.ok(settings.getCurrent()); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(SettingsJson.class); + } + } + + @PutMapping( + path = "/settings", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity updateSettings(@RequestBody SettingsJson newSettings) { + try { + var adminUser = admins.checkAdminUser(); + + settings.updateFromJson(newSettings); + + var json = settings.getCurrent(); + // TODO: indicate in the logs which setting was changed + json.setSuccess("Updated runtime settings"); + logs.logAction(adminUser, json); + + return ResponseEntity.ok(json); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(SettingsJson.class); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/SettingsJson.java b/server/src/main/java/org/eclipse/openvsx/json/SettingsJson.java new file mode 100644 index 000000000..5eca1f19b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/SettingsJson.java @@ -0,0 +1,31 @@ +/****************************************************************************** + * 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.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + + +@JsonInclude(Include.NON_NULL) +public class SettingsJson extends ResultJson { + + private boolean readOnly; + + public boolean isReadOnly() { + return readOnly; + } + + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java index bca5894b5..7180146d8 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java @@ -33,7 +33,7 @@ public ReadOnlyEndpointAspect(SettingsService settingsService) { " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.UserAPI.*(..)) ||" + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.*API.*(..))) && @annotation(MutatingOperation)") public Object handleMutatingEndpoint(ProceedingJoinPoint joinPoint) throws Throwable { - if (settingsService.isRegistryReadOnly()) { + if (settingsService.isReadOnly()) { return ResponseEntity.status(409).body(ResultJson.error("Registry is in read-only mode.")); } else { return joinPoint.proceed(); diff --git a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java index 3397667ad..d3c574f26 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java @@ -12,15 +12,27 @@ *****************************************************************************/ package org.eclipse.openvsx.settings; +import org.eclipse.openvsx.json.SettingsJson; import org.springframework.stereotype.Service; @Service public class SettingsService { + private boolean readOnlyMode = false; + public SettingsService() {} - public boolean isRegistryReadOnly() { - // TODO: implement this - return true; + public boolean isReadOnly() { + return readOnlyMode; + } + + public SettingsJson getCurrent() { + var json = new SettingsJson(); + json.setReadOnly(isReadOnly()); + return json; + } + + public void updateFromJson(SettingsJson newSettings) { + readOnlyMode = newSettings.isReadOnly(); } } diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index a5140a07b..4cd16c93a 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -16,7 +16,7 @@ import { FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, CustomerMembershipList, RateLimitToken, - RuntimeFeatureFlags, + Settings, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -539,8 +539,8 @@ export interface AdminService { getCustomerRateLimitTokens(abortController: AbortController, customerName: string): Promise>; createCustomerRateLimitToken(abortController: AbortController, customerName: string, description: string): Promise>; deleteCustomerRateLimitToken(abortController: AbortController, customerName: string, tokenId: number): Promise>; - getRuntimeFeatureFlags(abortController: AbortController): Promise>; - updateRuntimeFeatureFlags(abortController: AbortController, featureFlags: RuntimeFeatureFlags): Promise>; + getSettings(abortController: AbortController): Promise>; + updateSettings(abortController: AbortController, settings: Settings): Promise>; } export interface AdminServiceConstructor { @@ -1153,15 +1153,15 @@ export class AdminServiceImpl implements AdminService { }, false); } - async getRuntimeFeatureFlags(abortController: AbortController): Promise> { - return new Promise((resolve, reject) => { - setTimeout(() => resolve({ - readOnlyMode: false - }), 1000); - }); + async getSettings(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'settings']), + }, false); } - async updateRuntimeFeatureFlags(abortController: AbortController, featureFlags: RuntimeFeatureFlags): Promise> { + async updateSettings(abortController: AbortController, settings: Settings): Promise> { const csrfResponse = await this.registry.getCsrfToken(abortController); const headers: Record = { 'Content-Type': 'application/json;charset=UTF-8' @@ -1174,9 +1174,9 @@ export class AdminServiceImpl implements AdminService { return sendRequest({ abortController, method: 'PUT', - payload: featureFlags, + payload: settings, credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'feature-flags']), + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'settings']), headers }, false); } diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index b6f864c83..dc12587d4 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -501,6 +501,6 @@ export interface LogPageableList { }; } -export interface RuntimeFeatureFlags { - readOnlyMode: boolean; +export interface Settings { + readOnly: boolean; } diff --git a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts index ceebf4611..f8837d9d3 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts +++ b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts @@ -20,6 +20,6 @@ export namespace AdminDashboardRoutes { export const TIERS = createRoute([ROOT, 'tiers']); export const CUSTOMERS = createRoute([ROOT, 'customers']); export const USAGE_STATS = createRoute([ROOT, 'usage']); - export const RUNTIME_FEATURE_FLAGS = createRoute([ROOT, 'settings']); + export const SETTINGS = createRoute([ROOT, 'settings']); export const LOGS = createRoute([ROOT, 'logs']); } diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 815b52022..a6cd666b1 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -43,7 +43,7 @@ import { Tiers } from './tiers/tiers'; import { Customers } from './customers/customers'; import { CustomerDetails } from './customers/customer-details'; import { Logs } from './logs/logs'; -import { RuntimeFeatureFlagsPage } from './runtime-feature-flags'; +import { RuntimeFeatureFlagsPage } from './settings'; import { Welcome } from './welcome'; const ExtensionAdmin = lazy(() => import('./extension-admin').then(m => ({ default: m.ExtensionAdmin }))); @@ -63,7 +63,7 @@ const navConfig: NavEntry[] = [ { path: AdminDashboardRoutes.USAGE_STATS, name: 'Usage Stats', icon: , description: 'Show usage stats for customers' }, ], }, - { path: AdminDashboardRoutes.RUNTIME_FEATURE_FLAGS, name: 'Settings', icon: , description: 'Manage runtime feature flags for the registry' }, + { path: AdminDashboardRoutes.SETTINGS, name: 'Settings', icon: , description: 'Manage runtime feature flags for the registry' }, { path: AdminDashboardRoutes.LOGS, name: 'Logs', icon: , description: 'Browse admin activity logs' }, ]; diff --git a/webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx b/webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx deleted file mode 100644 index 1aef504c6..000000000 --- a/webui/src/pages/admin-dashboard/runtime-feature-flag-item.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/****************************************************************************** - * 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 - *****************************************************************************/ - -import { ChangeEvent, FC } from 'react'; -import { Box, Skeleton, Switch, Typography } from '@mui/material'; - -export interface RuntimeFeatureFlagItemProps { - title: string; - description: string; - checked: boolean; - disabled?: boolean; - loading?: boolean; - onChange: (event: ChangeEvent, checked: boolean) => void; -} - -export const RuntimeFeatureFlagItem: FC = ({ - title, - description, - checked, - disabled, - loading, - onChange, -}) => { - return ( - - - - {title} - - - {description} - - - - {(loading) ? ( - - ) : ( - - )} - - - {description} - - - ); - }; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/settings-item.tsx b/webui/src/pages/admin-dashboard/settings-item.tsx new file mode 100644 index 000000000..44812e891 --- /dev/null +++ b/webui/src/pages/admin-dashboard/settings-item.tsx @@ -0,0 +1,70 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ + +import { ChangeEvent, FC } from 'react'; +import { Box, Skeleton, Switch, Typography } from '@mui/material'; + +export interface SettingsItemProps { + title: string; + description: string; + checked: boolean; + disabled?: boolean; + loading?: boolean; + onChange: (event: ChangeEvent, checked: boolean) => void; +} + +export const SettingsItem: FC = ({ + title, + description, + checked, + disabled, + loading, + onChange, + }) => { + return ( + + + + {title} + + + {description} + + + + {(loading) ? ( + + ) : ( + + )} + + + {description} + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/runtime-feature-flags.tsx b/webui/src/pages/admin-dashboard/settings.tsx similarity index 75% rename from webui/src/pages/admin-dashboard/runtime-feature-flags.tsx rename to webui/src/pages/admin-dashboard/settings.tsx index 541e42380..987ae5a47 100644 --- a/webui/src/pages/admin-dashboard/runtime-feature-flags.tsx +++ b/webui/src/pages/admin-dashboard/settings.tsx @@ -15,15 +15,14 @@ import { ChangeEvent, FC, useCallback, useContext, useEffect, useRef, useState } import { Alert, Box, - Divider, Paper, Stack, Typography, } from '@mui/material'; import { MainContext } from '../../context'; -import type { RuntimeFeatureFlags } from '../../extension-registry-types'; +import type { Settings } from '../../extension-registry-types'; import { handleError } from '../../utils'; -import { RuntimeFeatureFlagItem } from './runtime-feature-flag-item'; +import { SettingsItem } from './settings-item'; interface NotificationState { id: string; @@ -34,8 +33,8 @@ interface NotificationState { const NOTIFICATION_TIMEOUT = 5000; -const FEATURE_FLAGS: Record = { - readOnlyMode: { +const SETTINGS: Record = { + readOnly: { title: 'Read-only mode', description: 'Blocks write operations while keeping browsing, search, and downloads available.', }, @@ -45,7 +44,7 @@ export const RuntimeFeatureFlagsPage: FC = () => { const abortController = useRef(new AbortController()); const { service } = useContext(MainContext); - const [featureFlags, setFeatureFlags] = useState(null); + const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -59,8 +58,8 @@ export const RuntimeFeatureFlagsPage: FC = () => { try { setLoading(true); setError(null); - const data = await service.admin.getRuntimeFeatureFlags(abortController.current); - setFeatureFlags(data); + const data = await service.admin.getSettings(abortController.current); + setSettings(data); } catch (err) { setError(handleError(err as Error)); } finally { @@ -92,30 +91,30 @@ export const RuntimeFeatureFlagsPage: FC = () => { }); }; - const handleFlagChange = useCallback((key: keyof RuntimeFeatureFlags) => async (_event: ChangeEvent, checked: boolean) => { - if (!featureFlags || saving) return; + const handleFlagChange = useCallback((key: keyof Settings) => async (_event: ChangeEvent, checked: boolean) => { + if (!settings || saving) return; - const previousFeatureFlags = featureFlags; - const nextFeatureFlags: RuntimeFeatureFlags = { ...featureFlags, [key]: checked }; + const previousSettings = settings; + const nextSettings: Settings = { ...settings, [key]: checked }; - setFeatureFlags(nextFeatureFlags); + setSettings(nextSettings); setSaving(true); setError(null); try { - const updatedFeatureFlags = await service.admin.updateRuntimeFeatureFlags(abortController.current, nextFeatureFlags); - setFeatureFlags(updatedFeatureFlags); + const updatedSettings = await service.admin.updateSettings(abortController.current, nextSettings); + setSettings(updatedSettings); addNotification({ severity: 'success', message: 'Runtime feature flags saved.' }); } catch (err) { - setFeatureFlags(previousFeatureFlags); + setSettings(previousSettings); addNotification({ severity: 'error', - message: `Failed to save runtime feature flags. ${handleError(err as Error)}`, + message: `Failed to save runtime settings. ${handleError(err as Error)}`, }); } finally { setSaving(false); } - }, [featureFlags, saving, service, addNotification]); + }, [settings, saving, service, addNotification]); return ( <> @@ -136,14 +135,14 @@ export const RuntimeFeatureFlagsPage: FC = () => { )} - {(Object.entries(FEATURE_FLAGS) as [keyof RuntimeFeatureFlags, { title: string; description: string }][]).map(([key, flag]) => ( - ( + ))} From bf3dd1e075309c31584f6091e3c91abc993fa2e0 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 13 May 2026 11:11:51 +0200 Subject: [PATCH 06/14] consistently use settings instead of feature flags, reduce notification timeout, add label --- .../src/pages/admin-dashboard/admin-dashboard.tsx | 6 +++--- webui/src/pages/admin-dashboard/settings-item.tsx | 14 ++++++++------ webui/src/pages/admin-dashboard/settings.tsx | 14 +++++++------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index a6cd666b1..ad4da7927 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -43,7 +43,7 @@ import { Tiers } from './tiers/tiers'; import { Customers } from './customers/customers'; import { CustomerDetails } from './customers/customer-details'; import { Logs } from './logs/logs'; -import { RuntimeFeatureFlagsPage } from './settings'; +import { RuntimeSettingsPage } from './settings'; import { Welcome } from './welcome'; const ExtensionAdmin = lazy(() => import('./extension-admin').then(m => ({ default: m.ExtensionAdmin }))); @@ -63,7 +63,7 @@ const navConfig: NavEntry[] = [ { path: AdminDashboardRoutes.USAGE_STATS, name: 'Usage Stats', icon: , description: 'Show usage stats for customers' }, ], }, - { path: AdminDashboardRoutes.SETTINGS, name: 'Settings', icon: , description: 'Manage runtime feature flags for the registry' }, + { path: AdminDashboardRoutes.SETTINGS, name: 'Settings', icon: , description: 'Manage runtime settings for the registry' }, { path: AdminDashboardRoutes.LOGS, name: 'Logs', icon: , description: 'Browse admin activity logs' }, ]; @@ -139,7 +139,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/settings-item.tsx b/webui/src/pages/admin-dashboard/settings-item.tsx index 44812e891..cbec90fc7 100644 --- a/webui/src/pages/admin-dashboard/settings-item.tsx +++ b/webui/src/pages/admin-dashboard/settings-item.tsx @@ -12,7 +12,7 @@ *****************************************************************************/ import { ChangeEvent, FC } from 'react'; -import { Box, Skeleton, Switch, Typography } from '@mui/material'; +import { Box, Skeleton, Switch, Typography, FormGroup, FormControlLabel } from '@mui/material'; export interface SettingsItemProps { title: string; @@ -54,11 +54,13 @@ export const SettingsItem: FC = ({ {(loading) ? ( ) : ( - + + } + label={checked ? "Enabled" : "Disabled"} /> + )} ; } -const NOTIFICATION_TIMEOUT = 5000; +const NOTIFICATION_TIMEOUT = 2000; const SETTINGS: Record = { readOnly: { @@ -40,7 +40,7 @@ const SETTINGS: Record = }, }; -export const RuntimeFeatureFlagsPage: FC = () => { +export const RuntimeSettingsPage: FC = () => { const abortController = useRef(new AbortController()); const { service } = useContext(MainContext); @@ -54,7 +54,7 @@ export const RuntimeFeatureFlagsPage: FC = () => { return () => abortController.current.abort(); }, []); - const loadRuntimeFeatureFlags = useCallback(async () => { + const loadRuntimeSettings = useCallback(async () => { try { setLoading(true); setError(null); @@ -68,8 +68,8 @@ export const RuntimeFeatureFlagsPage: FC = () => { }, [service]); useEffect(() => { - loadRuntimeFeatureFlags(); - }, [loadRuntimeFeatureFlags]); + loadRuntimeSettings(); + }, [loadRuntimeSettings]); useEffect(() => () => { notifications.forEach(n => clearTimeout(n.timeout)); @@ -104,7 +104,7 @@ export const RuntimeFeatureFlagsPage: FC = () => { try { const updatedSettings = await service.admin.updateSettings(abortController.current, nextSettings); setSettings(updatedSettings); - addNotification({ severity: 'success', message: 'Runtime feature flags saved.' }); + addNotification({ severity: 'success', message: 'Runtime settings saved.' }); } catch (err) { setSettings(previousSettings); addNotification({ @@ -124,7 +124,7 @@ export const RuntimeFeatureFlagsPage: FC = () => { Settings - Manage runtime feature flags that apply across the registry. + Manage runtime settings that apply across the registry. From cfe4081539fc7d5165fe8434d9461060149ae7ca Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 13 May 2026 14:09:56 +0200 Subject: [PATCH 07/14] bump jedis to 7.4.1 and refactor channel listener to a common base class --- server/gradle/libs.versions.toml | 2 +- .../JCacheBucket4jConfiguration.java | 15 ++-- .../JedisClusterBucket4jConfiguration.java | 15 ++-- .../JedisClusterCacheListener.java | 15 ++-- .../JedisClusterCacheManager.java | 15 ++-- .../JedisClusterCacheResolver.java | 15 ++-- .../jedis/JedisClusterChannelListener.java | 88 +++++++++++++++++++ .../cache/RateLimitCacheService.java | 61 +------------ .../scanning/GitleaksRulesService.java | 71 ++++----------- 9 files changed, 156 insertions(+), 141 deletions(-) rename server/src/main/java/org/eclipse/openvsx/cache/{ => bucket4j}/JCacheBucket4jConfiguration.java (71%) rename server/src/main/java/org/eclipse/openvsx/cache/{ => bucket4j}/JedisClusterBucket4jConfiguration.java (85%) rename server/src/main/java/org/eclipse/openvsx/cache/{ => bucket4j}/JedisClusterCacheListener.java (90%) rename server/src/main/java/org/eclipse/openvsx/cache/{ => bucket4j}/JedisClusterCacheManager.java (85%) rename server/src/main/java/org/eclipse/openvsx/cache/{ => bucket4j}/JedisClusterCacheResolver.java (74%) create mode 100644 server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java diff --git a/server/gradle/libs.versions.toml b/server/gradle/libs.versions.toml index 10de5ec27..d950a1ef3 100644 --- a/server/gradle/libs.versions.toml +++ b/server/gradle/libs.versions.toml @@ -14,7 +14,7 @@ jackson = "2.18.6" java = "25" jaxb-api = "2.3.1" jaxb-impl = "2.3.8" -jedis = "6.2.0" +jedis = "7.4.1" jobrunr = "7.5.3" jooq = "3.19.34" jsonpath = "2.9.0" diff --git a/server/src/main/java/org/eclipse/openvsx/cache/JCacheBucket4jConfiguration.java b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JCacheBucket4jConfiguration.java similarity index 71% rename from server/src/main/java/org/eclipse/openvsx/cache/JCacheBucket4jConfiguration.java rename to server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JCacheBucket4jConfiguration.java index cfe95890b..7fda30ad3 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/JCacheBucket4jConfiguration.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JCacheBucket4jConfiguration.java @@ -1,13 +1,16 @@ -/** ****************************************************************************** - * Copyright (c) 2025 Precies. Software OU and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.cache; + *****************************************************************************/ +package org.eclipse.openvsx.cache.bucket4j; import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; import com.giffing.bucket4j.spring.boot.starter.config.cache.jcache.JCacheCacheResolver; diff --git a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterBucket4jConfiguration.java b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterBucket4jConfiguration.java similarity index 85% rename from server/src/main/java/org/eclipse/openvsx/cache/JedisClusterBucket4jConfiguration.java rename to server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterBucket4jConfiguration.java index e2f18c064..90bf27c81 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterBucket4jConfiguration.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterBucket4jConfiguration.java @@ -1,13 +1,16 @@ -/** ****************************************************************************** - * Copyright (c) 2025 Precies. Software OU and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.cache; + *****************************************************************************/ +package org.eclipse.openvsx.cache.bucket4j; import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; diff --git a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheListener.java b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheListener.java similarity index 90% rename from server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheListener.java rename to server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheListener.java index 4538521df..27223ea42 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheListener.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheListener.java @@ -1,13 +1,16 @@ -/** ****************************************************************************** - * Copyright (c) 2025 Precies. Software OU and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.cache; + *****************************************************************************/ +package org.eclipse.openvsx.cache.bucket4j; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JavaType; diff --git a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheManager.java b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheManager.java similarity index 85% rename from server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheManager.java rename to server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheManager.java index 2d1ad5564..1202e60cb 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheManager.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheManager.java @@ -1,13 +1,16 @@ -/** ****************************************************************************** - * Copyright (c) 2025 Precies. Software OU and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.cache; + *****************************************************************************/ +package org.eclipse.openvsx.cache.bucket4j; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheResolver.java b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheResolver.java similarity index 74% rename from server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheResolver.java rename to server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheResolver.java index 931b16bb6..9a24ba5d6 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/JedisClusterCacheResolver.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/bucket4j/JedisClusterCacheResolver.java @@ -1,13 +1,16 @@ -/** ****************************************************************************** - * Copyright (c) 2025 Precies. Software OU and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.cache; + *****************************************************************************/ +package org.eclipse.openvsx.cache.bucket4j; import com.giffing.bucket4j.spring.boot.starter.config.cache.AbstractCacheResolverTemplate; import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; diff --git a/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java b/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java new file mode 100644 index 000000000..2ddf43dac --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java @@ -0,0 +1,88 @@ +/****************************************************************************** + * 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.cache.jedis; + +import io.micrometer.core.instrument.util.NamedThreadFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.JedisCluster; +import redis.clients.jedis.JedisPubSub; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public abstract class JedisClusterChannelListener extends JedisPubSub { + private final Logger logger = LoggerFactory.getLogger(JedisClusterChannelListener.class); + + private final JedisCluster jedisCluster; + private final String channelName; + private final String listenerName; + + // Redis subscriber state + private volatile Thread subscriberThread; + private volatile boolean running = true; + + public JedisClusterChannelListener(JedisCluster jedisCluster, String channelName, String listenerName) { + this.jedisCluster = jedisCluster; + this.channelName = channelName; + this.listenerName = listenerName; + } + + public void startSubscriber() { + subscriberThread = new Thread(this::subscribeLoop, listenerName + "Subscriber"); + subscriberThread.setDaemon(true); + subscriberThread.start(); + } + + public void shutdown() { + running = false; + if (isSubscribed()) { + unsubscribe(); + } + if (subscriberThread != null) { + subscriberThread.interrupt(); + } + } + + private void subscribeLoop() { + AtomicInteger backoffMs = new AtomicInteger(1000); + try (var executor = Executors.newSingleThreadScheduledExecutor( + new NamedThreadFactory("rate-limit-config-subscriber-reconnect") + )) { + while (running && !Thread.currentThread().isInterrupted()) { + ScheduledFuture resetTask = null; + try { + resetTask = executor.schedule(() -> backoffMs.set(1000), 10, TimeUnit.SECONDS); + logger.debug("Subscribing to redis channel {}", channelName); + jedisCluster.subscribe(this, channelName); + } catch (Exception e) { + if (!running) break; + logger.warn( + "Redis pubsub subscriber for channel {} disconnected, reconnecting in {}s: {}", + channelName, backoffMs.get() / 1000, e.getMessage() + ); + if (resetTask != null) resetTask.cancel(true); + try { + Thread.sleep(backoffMs.get()); + backoffMs.set(Math.min(backoffMs.get() * 2, 30000)); + } catch (InterruptedException ignored) { + break; + } + } + } + executor.shutdownNow(); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java index bc2a3c343..762c2fbe5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java @@ -12,9 +12,9 @@ *****************************************************************************/ package org.eclipse.openvsx.ratelimit.cache; -import io.micrometer.core.instrument.util.NamedThreadFactory; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import org.eclipse.openvsx.cache.jedis.JedisClusterChannelListener; import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,11 +26,6 @@ import redis.clients.jedis.JedisCluster; import redis.clients.jedis.JedisPubSub; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - @Service @ConditionalOnBean(RateLimitConfig.class) public class RateLimitCacheService extends JedisPubSub { @@ -124,59 +119,9 @@ public void evictTokens(String[] tokens) { } } - private class ConfigCacheUpdateListener extends JedisPubSub { - private final JedisCluster jedisCluster; - - // Redis subscriber state - private volatile Thread subscriberThread; - private volatile boolean running = true; - + private class ConfigCacheUpdateListener extends JedisClusterChannelListener { public ConfigCacheUpdateListener(JedisCluster jedisCluster) { - this.jedisCluster = jedisCluster; - } - - void startSubscriber() { - subscriberThread = new Thread(this::subscribeLoop, "RateLimitConfigSubscriber"); - subscriberThread.setDaemon(true); - subscriberThread.start(); - } - - void shutdown() { - running = false; - if (isSubscribed()) { - unsubscribe(); - } - if (subscriberThread != null) { - subscriberThread.interrupt(); - } - } - - private void subscribeLoop() { - AtomicInteger backoffMs = new AtomicInteger(1000); - try (var executor = Executors.newSingleThreadScheduledExecutor( - new NamedThreadFactory("rate-limit-config-subscriber-reconnect") - )) { - while (running && !Thread.currentThread().isInterrupted()) { - ScheduledFuture resetTask = null; - try { - resetTask = executor.schedule(() -> backoffMs.set(1000), 10, TimeUnit.SECONDS); - logger.debug("Subscribing to rate-limit config update channel"); - jedisCluster.subscribe(this, CONFIG_UPDATE_CHANNEL); - } catch (Exception e) { - if (!running) break; - logger.warn("Rate-limit config subscriber disconnected, reconnecting in {}s: {}", - backoffMs.get() / 1000, e.getMessage()); - if (resetTask != null) resetTask.cancel(true); - try { - Thread.sleep(backoffMs.get()); - backoffMs.set(Math.min(backoffMs.get() * 2, 30000)); - } catch (InterruptedException ignored) { - break; - } - } - } - executor.shutdownNow(); - } + super(jedisCluster, CONFIG_UPDATE_CHANNEL, "RateLimitConfig"); } @Override diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesService.java b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesService.java index ffaa8ca03..774f24f2e 100644 --- a/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesService.java +++ b/server/src/main/java/org/eclipse/openvsx/scanning/GitleaksRulesService.java @@ -15,7 +15,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.toml.TomlMapper; -import io.micrometer.core.instrument.util.NamedThreadFactory; +import org.eclipse.openvsx.cache.jedis.JedisClusterChannelListener; import org.eclipse.openvsx.migration.HandlerJobRequest; import org.jobrunr.jobs.annotations.Job; import org.jobrunr.jobs.lambdas.JobRequestHandler; @@ -25,7 +25,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import redis.clients.jedis.JedisCluster; -import redis.clients.jedis.JedisPubSub; import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; @@ -41,10 +40,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; /** @@ -59,7 +54,7 @@ */ @Service @ConditionalOnProperty(name = "ovsx.scanning.secret-detection.gitleaks.auto-fetch", havingValue = "true") -public class GitleaksRulesService extends JedisPubSub implements JobRequestHandler> { +public class GitleaksRulesService implements JobRequestHandler> { private static final Logger logger = LoggerFactory.getLogger(GitleaksRulesService.class); @@ -74,6 +69,7 @@ public class GitleaksRulesService extends JedisPubSub implements JobRequestHandl private final SecretDetectorConfig config; private final ObjectProvider detectorFactoryProvider; private final JedisCluster jedisCluster; + private final RulesUpdateChannelListener rulesUpdateChannelListener; // Path to generated rules file private String generatedRulesPath; @@ -90,10 +86,12 @@ public GitleaksRulesService( this.config = config; this.detectorFactoryProvider = detectorFactoryProvider; this.jedisCluster = jedisCluster; - + if (jedisCluster != null) { + this.rulesUpdateChannelListener = new RulesUpdateChannelListener(jedisCluster); logger.debug("GitleaksRulesService initialized with Redis sync"); } else { + this.rulesUpdateChannelListener = null; logger.debug("GitleaksRulesService initialized (local only, no Redis)"); } } @@ -108,20 +106,16 @@ public void initialize() { generateRulesIfNeeded(); // Start Redis subscriber if available - if (jedisCluster != null) { - startRedisSubscriber(); + if (rulesUpdateChannelListener != null) { + rulesUpdateChannelListener.startSubscriber(); loadRulesFromRedisIfNewer(); } } @PreDestroy public void shutdown() { - running = false; - if (isSubscribed()) { - unsubscribe(); - } - if (subscriberThread != null) { - subscriberThread.interrupt(); + if (rulesUpdateChannelListener != null) { + rulesUpdateChannelListener.shutdown(); } } @@ -230,44 +224,17 @@ public void run(HandlerJobRequest jobRequest) throws Exception { } } - private void startRedisSubscriber() { - subscriberThread = new Thread(this::subscribeLoop, "GitleaksRulesSubscriber"); - subscriberThread.setDaemon(true); - subscriberThread.start(); - } - - private void subscribeLoop() { - AtomicInteger backoffMs = new AtomicInteger(1000); - - try (var executor = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("gitleaks-rules-reconnect"))) { - while (running && !Thread.currentThread().isInterrupted()) { - ScheduledFuture resetTask = null; - try { - resetTask = executor.schedule(() -> backoffMs.set(1000), 10, TimeUnit.SECONDS); - logger.debug("Subscribing to gitleaks rules update channel"); - jedisCluster.subscribe(this, RULES_UPDATE_CHANNEL); - } catch (Exception e) { - if (!running) break; - logger.warn("Gitleaks rules subscriber disconnected, reconnecting in {}s: {}", - backoffMs.get() / 1000, e.getMessage()); - if (resetTask != null) resetTask.cancel(true); - try { - Thread.sleep(backoffMs.get()); - backoffMs.set(Math.min(backoffMs.get() * 2, 30000)); - } catch (InterruptedException ignored) { - break; - } - } - } - executor.shutdownNow(); + private class RulesUpdateChannelListener extends JedisClusterChannelListener { + RulesUpdateChannelListener(JedisCluster jedisCluster) { + super(jedisCluster, RULES_UPDATE_CHANNEL, "GitleaksRules"); } - } - @Override - public void onMessage(String channel, String message) { - if (RULES_UPDATE_CHANNEL.equals(channel)) { - logger.debug("Received gitleaks rules update notification from another pod"); - loadRulesFromRedis(); + @Override + public void onMessage(String channel, String message) { + if (RULES_UPDATE_CHANNEL.equals(channel)) { + logger.debug("Received gitleaks rules update notification from another pod"); + loadRulesFromRedis(); + } } } From 6fd970e58bc662d97f75e0b5b33b877a006706f1 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 13 May 2026 14:26:35 +0200 Subject: [PATCH 08/14] add update listener to SettingsService --- .../jedis/JedisClusterChannelListener.java | 2 + .../openvsx/settings/SettingsService.java | 61 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java b/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java index 2ddf43dac..c609dd01d 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java @@ -56,6 +56,8 @@ public void shutdown() { } } + public abstract void onMessage(String channel, String message); + private void subscribeLoop() { AtomicInteger backoffMs = new AtomicInteger(1000); try (var executor = Executors.newSingleThreadScheduledExecutor( diff --git a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java index d3c574f26..a6e505480 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java @@ -12,15 +12,60 @@ *****************************************************************************/ package org.eclipse.openvsx.settings; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.eclipse.openvsx.cache.jedis.JedisClusterChannelListener; import org.eclipse.openvsx.json.SettingsJson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import redis.clients.jedis.JedisCluster; @Service public class SettingsService { + private static final String SETTINGS_UPDATE_CHANNEL = "settings"; + + private final Logger logger = LoggerFactory.getLogger(SettingsService.class); + + private final @Nullable JedisCluster jedisCluster; + private final SettingsUpdateListener settingsUpdateListener; + private boolean readOnlyMode = false; - public SettingsService() {} + public SettingsService(@Nullable JedisCluster jedisCluster) { + this.jedisCluster = jedisCluster; + + if (jedisCluster != null) { + settingsUpdateListener = new SettingsUpdateListener(jedisCluster); + logger.info("SettingsService initialized with Redis update listener"); + } else { + settingsUpdateListener = null; + } + } + + private void publishSettingsUpdate() { + if (jedisCluster != null) { + logger.debug("Publish settings update"); + String version = String.valueOf(System.currentTimeMillis()); + jedisCluster.publish(SETTINGS_UPDATE_CHANNEL, version); + } + } + + @PostConstruct + public void initialize() { + if (settingsUpdateListener != null) { + settingsUpdateListener.startSubscriber(); + } + } + + @PreDestroy + public void shutdown() { + if (settingsUpdateListener != null) { + settingsUpdateListener.shutdown(); + } + } public boolean isReadOnly() { return readOnlyMode; @@ -34,5 +79,19 @@ public SettingsJson getCurrent() { public void updateFromJson(SettingsJson newSettings) { readOnlyMode = newSettings.isReadOnly(); + publishSettingsUpdate(); + } + + private class SettingsUpdateListener extends JedisClusterChannelListener { + SettingsUpdateListener(JedisCluster jedisCluster) { + super(jedisCluster, SETTINGS_UPDATE_CHANNEL, "SettingsUpdate"); + } + + @Override + public void onMessage(String channel, String message) { + if (SETTINGS_UPDATE_CHANNEL.equals(channel)) { + logger.info("received settings update"); + } + } } } From 9f11c197881baf25ea5c0526f54d6b0e500703ca Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 13 May 2026 14:51:04 +0200 Subject: [PATCH 09/14] fix ide warnings --- .../org/eclipse/openvsx/settings/MutatingOperation.java | 2 +- .../eclipse/openvsx/settings/ReadOnlyEndpointAspect.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java b/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java index 133b38c4b..a0a40d9d4 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java @@ -15,7 +15,7 @@ import java.lang.annotation.*; /** - * A marker annotation to indicate an operation that mutates the DB. + * A marker annotation to indicate that the annotated operation does database mutations. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) diff --git a/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java index 7180146d8..a98b480b4 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java @@ -23,17 +23,17 @@ @Component public class ReadOnlyEndpointAspect { - private final SettingsService settingsService; + private final SettingsService settings; - public ReadOnlyEndpointAspect(SettingsService settingsService) { - this.settingsService = settingsService; + public ReadOnlyEndpointAspect(SettingsService settings) { + this.settings = settings; } @Around("(execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.RegistryAPI.*(..)) ||" + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.UserAPI.*(..)) ||" + " execution(org.springframework.http.ResponseEntity org.eclipse.openvsx.admin.*API.*(..))) && @annotation(MutatingOperation)") public Object handleMutatingEndpoint(ProceedingJoinPoint joinPoint) throws Throwable { - if (settingsService.isReadOnly()) { + if (settings.isReadOnly()) { return ResponseEntity.status(409).body(ResultJson.error("Registry is in read-only mode.")); } else { return joinPoint.proceed(); From bcf87a68649a26dba5e6fd0a4b79bd5005510373 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 13 May 2026 23:08:46 +0200 Subject: [PATCH 10/14] add persistency layer for settings --- .../org/eclipse/openvsx/admin/AdminAPI.java | 10 +- .../eclipse/openvsx/cache/CacheConfig.java | 23 +++- .../eclipse/openvsx/cache/CacheService.java | 9 +- .../org/eclipse/openvsx/entities/Setting.java | 129 ++++++++++++++++++ .../MigrationItemJobRequestHandler.java | 4 +- .../openvsx/migration/MigrationScheduler.java | 2 +- .../repositories/SettingRepository.java | 38 ++++++ .../openvsx/settings/SettingsCache.java | 53 +++++++ .../openvsx/settings/SettingsService.java | 44 +++--- .../resources/db/migration/V1_69__Setting.sql | 16 +++ .../eclipse/openvsx/admin/AdminAPITest.java | 10 +- 11 files changed, 303 insertions(+), 35 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/Setting.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/SettingRepository.java create mode 100644 server/src/main/java/org/eclipse/openvsx/settings/SettingsCache.java create mode 100644 server/src/main/resources/db/migration/V1_69__Setting.sql 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 1355099e3..8c31ec31e 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -609,7 +609,7 @@ public ResponseEntity revokePublisherTokens(@PathVariable String log public ResponseEntity getSettings() { try { admins.checkAdminUser(); - return ResponseEntity.ok(settings.getCurrent()); + return ResponseEntity.ok(settings.getCurrentSettings()); } catch (ErrorResultException exc) { return exc.toResponseEntity(SettingsJson.class); } @@ -624,11 +624,9 @@ public ResponseEntity updateSettings(@RequestBody SettingsJson new try { var adminUser = admins.checkAdminUser(); - settings.updateFromJson(newSettings); - - var json = settings.getCurrent(); - // TODO: indicate in the logs which setting was changed - json.setSuccess("Updated runtime settings"); + var changes = settings.updateFromJson(newSettings); + var json = settings.getCurrentSettings(); + json.setSuccess("Updated settings: " + changes); logs.logAction(adminUser, json); return ResponseEntity.ok(json); diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index 562dc05aa..e9063cc55 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java @@ -107,13 +107,34 @@ public Cache browseCache( public @Qualifier("fileCacheManager") CacheManager fileCacheManager( Cache extensionCache, Cache webResourceCache, - Cache browseCache + Cache browseCache, + Cache settingCache ) { logger.info("Configure file cache manager"); CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); caffeineCacheManager.registerCustomCache(CACHE_EXTENSION_FILES, extensionCache); caffeineCacheManager.registerCustomCache(CACHE_WEB_RESOURCE_FILES, webResourceCache); caffeineCacheManager.registerCustomCache(CACHE_BROWSE_EXTENSION_FILES, browseCache); + caffeineCacheManager.registerCustomCache(CACHE_SETTING, settingCache); + + return caffeineCacheManager; + } + + @Bean + public Cache settingCache() { + return Caffeine.newBuilder() + .scheduler(Scheduler.systemScheduler()) + .recordStats() + .build(); + } + + @Bean + public @Qualifier("localCacheManager") CacheManager localCacheManager( + Cache settingCache + ) { + logger.info("Configure local cache manager"); + CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); + caffeineCacheManager.registerCustomCache(CACHE_SETTING, settingCache); return caffeineCacheManager; } 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 d1ecaa7de..0f531fe5d 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java @@ -38,6 +38,7 @@ public class CacheService { 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 CACHE_SETTING = "settings"; public static final String GENERATOR_EXTENSION_JSON = "extensionJsonCacheKeyGenerator"; public static final String GENERATOR_LATEST_EXTENSION_VERSION = "latestExtensionVersionCacheKeyGenerator"; @@ -88,7 +89,7 @@ public void evictNamespaceDetails(Extension extension) { private void evictNamespaceDetails(String namespaceName) { var cache = cacheManager.getCache(CACHE_NAMESPACE_DETAILS_JSON); - if(cache == null) { + if (cache == null) { return; // cache is not created } @@ -220,7 +221,7 @@ private void evictInternalLatestExtensionVersionVSCode(Extension extension) { private void invalidateCache(String cacheName) { var cache = cacheManager.getCache(cacheName); - if(cache == null) { + if (cache == null) { return; } @@ -229,7 +230,7 @@ private void invalidateCache(String cacheName) { public void evictExtensionFile(FileResource download) { var cache = fileCacheManager.getCache(CACHE_EXTENSION_FILES); - if(cache == null) { + if (cache == null) { return; } @@ -239,7 +240,7 @@ public void evictExtensionFile(FileResource download) { @Observed public void evictWebResourceFile(String namespaceName, String extensionName, String targetPlatform, String version, String path) { var cache = fileCacheManager.getCache(CACHE_WEB_RESOURCE_FILES); - if(cache == null) { + if (cache == null) { return; } diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Setting.java b/server/src/main/java/org/eclipse/openvsx/entities/Setting.java new file mode 100644 index 000000000..1049dcb9f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/Setting.java @@ -0,0 +1,129 @@ +/****************************************************************************** + * 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.entities; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.eclipse.openvsx.util.TimeUtil; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +/** + * Persistent entity for storing key-value settings scoped to any entity + * (user, organization, system, etc.). + * + * DDL (PostgreSQL): + * + * CREATE TABLE settings ( + * id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + * entity_type VARCHAR(50) NOT NULL, + * entity_id UUID NOT NULL, + * key VARCHAR(255) NOT NULL, + * value JSONB, + * created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + * updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + * CONSTRAINT uq_settings UNIQUE (entity_type, entity_id, key) + * ); + */ +@Entity +public class Setting { + @Id + @GeneratedValue(generator = "settingSeq") + @SequenceGenerator(name = "settingSeq", sequenceName = "setting_seq", allocationSize = 1) + private long id; + + @NotBlank + @Size(max = 255) + @Column(name = "key", nullable = false, updatable = false) + private String key; + + /** + * Value stored as JSONB so it can hold any scalar or nested structure: + * "dark" → String + * 42 → Number + * true → Boolean + * {"r":255,"g":0} → Object + * ["en","fr"] → Array + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "value", columnDefinition = "jsonb", nullable = false) + private String value; + + // ------------------------------------------------------------------------- + // Audit + // ------------------------------------------------------------------------- + + @NotNull + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @NotNull + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + // ------------------------------------------------------------------------- + // Lifecycle hooks + // ------------------------------------------------------------------------- + + @PrePersist + private void onCreate() { + var now = TimeUtil.getCurrentUTC(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void onUpdate() { + this.updatedAt = TimeUtil.getCurrentUTC(); + } + + // ------------------------------------------------------------------------- + // Getters (immutable after construction — use service layer to update) + // ------------------------------------------------------------------------- + + public long getId() { return id; } + public String getKey() { return key; } + public String getValue() { return value; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + + /** The only mutable field — values change but keys stay stable. */ + public void setValue(String value) { + this.value = value; + } + + // ------------------------------------------------------------------------- + // Equality — based on natural key, not surrogate PK + // ------------------------------------------------------------------------- + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Setting other)) return false; + return key.equals(other.key); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(key); + } + + @Override + public String toString() { + return "Setting{key='%s'}".formatted(key); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/migration/MigrationItemJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/migration/MigrationItemJobRequestHandler.java index cd7943e13..108381251 100644 --- a/server/src/main/java/org/eclipse/openvsx/migration/MigrationItemJobRequestHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/migration/MigrationItemJobRequestHandler.java @@ -43,12 +43,12 @@ public MigrationItemJobRequestHandler( @Override public void run(HandlerJobRequest jobRequest) throws Exception { var items = repositories.findNotMigratedItems(PageRequest.ofSize(25000)); - for(var item : items) { + for (var item : items) { migrations.enqueueMigration(item); } logger.info("Scheduled migration items: {}", items.getNumberOfElements()); - if(!items.hasNext()) { + if (!items.hasNext()) { logger.info("Migration completed, deleting recurring job"); scheduler.deleteScheduleMigrationItemsJob(); } diff --git a/server/src/main/java/org/eclipse/openvsx/migration/MigrationScheduler.java b/server/src/main/java/org/eclipse/openvsx/migration/MigrationScheduler.java index 9a2872d8b..a8c7d254c 100644 --- a/server/src/main/java/org/eclipse/openvsx/migration/MigrationScheduler.java +++ b/server/src/main/java/org/eclipse/openvsx/migration/MigrationScheduler.java @@ -39,7 +39,7 @@ public MigrationScheduler( @Job(name = "Schedule migrations", retries = 0) public void run(HandlerJobRequest jobRequest) throws Exception { orphanNamespaceMigration.fixOrphanNamespaces(); - if(!mirrorEnabled) { + if (!mirrorEnabled) { scheduler.enqueue(new HandlerJobRequest<>(GenerateKeyPairJobRequestHandler.class)); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/SettingRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/SettingRepository.java new file mode 100644 index 000000000..105e86586 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/SettingRepository.java @@ -0,0 +1,38 @@ +/****************************************************************************** + * 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.repositories; + +import org.eclipse.openvsx.entities.Setting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface SettingRepository extends JpaRepository { + + Optional findByKey(String key); + + @Modifying + @Query(value = """ + INSERT INTO setting (key, value, created_at, updated_at) + VALUES (:key, CAST(:value AS jsonb), :now, :now) + ON CONFLICT ON CONSTRAINT setting_key_unique + DO UPDATE SET value = CAST(:value AS jsonb), updated_at = :now + """, nativeQuery = true) + void upsert(@Param("key") String key, @Param("value") String value, @Param("now") LocalDateTime now); +} diff --git a/server/src/main/java/org/eclipse/openvsx/settings/SettingsCache.java b/server/src/main/java/org/eclipse/openvsx/settings/SettingsCache.java new file mode 100644 index 000000000..9c6d42371 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsCache.java @@ -0,0 +1,53 @@ +/****************************************************************************** + * 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.settings; + +import org.eclipse.openvsx.entities.Setting; +import org.eclipse.openvsx.repositories.SettingRepository; +import org.eclipse.openvsx.util.TimeUtil; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import static org.eclipse.openvsx.cache.CacheService.CACHE_SETTING; + +@Component +@CacheConfig(cacheManager = "localCacheManager") +public class SettingsCache { + + private final SettingRepository repository; + + public SettingsCache(SettingRepository repository) { + this.repository = repository; + } + + @Cacheable(value = CACHE_SETTING, key = "#key") + public Boolean getBoolean(String key, boolean defaultValue) { + return repository + .findByKey(key) + .map(Setting::getValue) + .map(Boolean::parseBoolean) + .orElse(defaultValue); + } + + @Transactional + @CacheEvict(value = CACHE_SETTING, key = "#key") + public void setBoolean(String key, boolean value) { + repository.upsert(key, String.valueOf(value), TimeUtil.getCurrentUTC()); + } + + @CacheEvict(value = CACHE_SETTING, allEntries = true) + public void clear() {} +} diff --git a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java index a6e505480..7697581aa 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java @@ -15,6 +15,7 @@ import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import org.apache.logging.log4j.util.Strings; import org.eclipse.openvsx.cache.jedis.JedisClusterChannelListener; import org.eclipse.openvsx.json.SettingsJson; import org.slf4j.Logger; @@ -22,20 +23,23 @@ import org.springframework.stereotype.Service; import redis.clients.jedis.JedisCluster; +import java.util.ArrayList; + @Service public class SettingsService { - private static final String SETTINGS_UPDATE_CHANNEL = "settings"; + public static final String SETTING_REGISTRY_READ_ONLY = "registry.read-only"; + private static final String SETTINGS_UPDATE_CHANNEL = "settings.update"; private final Logger logger = LoggerFactory.getLogger(SettingsService.class); private final @Nullable JedisCluster jedisCluster; private final SettingsUpdateListener settingsUpdateListener; + private final SettingsCache cache; - private boolean readOnlyMode = false; - - public SettingsService(@Nullable JedisCluster jedisCluster) { + public SettingsService(@Nullable JedisCluster jedisCluster, SettingsCache cache) { this.jedisCluster = jedisCluster; + this.cache = cache; if (jedisCluster != null) { settingsUpdateListener = new SettingsUpdateListener(jedisCluster); @@ -45,14 +49,6 @@ public SettingsService(@Nullable JedisCluster jedisCluster) { } } - private void publishSettingsUpdate() { - if (jedisCluster != null) { - logger.debug("Publish settings update"); - String version = String.valueOf(System.currentTimeMillis()); - jedisCluster.publish(SETTINGS_UPDATE_CHANNEL, version); - } - } - @PostConstruct public void initialize() { if (settingsUpdateListener != null) { @@ -68,18 +64,31 @@ public void shutdown() { } public boolean isReadOnly() { - return readOnlyMode; + return cache.getBoolean(SETTING_REGISTRY_READ_ONLY, false); } - public SettingsJson getCurrent() { + public SettingsJson getCurrentSettings() { var json = new SettingsJson(); json.setReadOnly(isReadOnly()); return json; } - public void updateFromJson(SettingsJson newSettings) { - readOnlyMode = newSettings.isReadOnly(); + public String updateFromJson(SettingsJson newSettings) { + var changes = new ArrayList<>(); + if (newSettings.isReadOnly() != isReadOnly()) { + changes.add("readOnly -> " + newSettings.isReadOnly()); + cache.setBoolean(SETTING_REGISTRY_READ_ONLY, newSettings.isReadOnly()); + } publishSettingsUpdate(); + return Strings.join(changes, ','); + } + + private void publishSettingsUpdate() { + if (jedisCluster != null) { + logger.debug("Publish settings update"); + String version = String.valueOf(System.currentTimeMillis()); + jedisCluster.publish(SETTINGS_UPDATE_CHANNEL, version); + } } private class SettingsUpdateListener extends JedisClusterChannelListener { @@ -90,7 +99,8 @@ private class SettingsUpdateListener extends JedisClusterChannelListener { @Override public void onMessage(String channel, String message) { if (SETTINGS_UPDATE_CHANNEL.equals(channel)) { - logger.info("received settings update"); + logger.debug("received settings update"); + cache.clear(); } } } diff --git a/server/src/main/resources/db/migration/V1_69__Setting.sql b/server/src/main/resources/db/migration/V1_69__Setting.sql new file mode 100644 index 000000000..45b1b982f --- /dev/null +++ b/server/src/main/resources/db/migration/V1_69__Setting.sql @@ -0,0 +1,16 @@ +-- setting table + +CREATE SEQUENCE IF NOT EXISTS setting_seq START WITH 1 INCREMENT BY 1; + +CREATE TABLE IF NOT EXISTS public.setting +( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('setting_seq'), + key CHARACTER VARYING(255) NOT NULL, + value JSONB NOT NULL, + created_at TIMESTAMP without time zone, + updated_at TIMESTAMP without time zone, + + -- constraints + + CONSTRAINT setting_key_unique UNIQUE (key) +); 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 b6ddd51af..203e59a98 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -77,6 +77,7 @@ import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; +import org.eclipse.openvsx.settings.SettingsService; import org.eclipse.openvsx.storage.AwsStorageService; import org.eclipse.openvsx.storage.AzureBlobStorageService; import org.eclipse.openvsx.storage.CdnServiceConfig; @@ -117,10 +118,11 @@ @AutoConfigureWebClient @MockitoBean(types = { ClientRegistrationRepository.class, UpstreamRegistryService.class, GoogleCloudStorageService.class, - AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, ExtensionDownloadMetrics.class, - CacheService.class, PublishExtensionVersionHandler.class, SearchUtilService.class, EclipseService.class, - SimpleMeterRegistry.class, FileCacheDurationConfig.class, MailService.class, CdnServiceConfig.class, - ExtensionScanService.class, ExtensionScanPersistenceService.class, LogService.class, AccessTokenConfig.class + AzureBlobStorageService.class, AwsStorageService.class, VSCodeIdService.class, DownloadCountService.class, + ExtensionDownloadMetrics.class, CacheService.class, PublishExtensionVersionHandler.class, SearchUtilService.class, + EclipseService.class, SimpleMeterRegistry.class, FileCacheDurationConfig.class, MailService.class, CdnServiceConfig.class, + ExtensionScanService.class, ExtensionScanPersistenceService.class, LogService.class, AccessTokenConfig.class, + SettingsService.class }) class AdminAPITest { From 6b52ec80dd617752c18036ca24529371e14726e7 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 14 May 2026 07:47:07 +0200 Subject: [PATCH 11/14] add expiry for setting cache --- .../src/main/java/org/eclipse/openvsx/cache/CacheConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index e9063cc55..dfb09c759 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java @@ -121,8 +121,11 @@ public Cache browseCache( } @Bean - public Cache settingCache() { + public Cache settingCache( + @Value("${ovsx.caching.setting.ttl:PT1M}") Duration timeToIdle + ) { return Caffeine.newBuilder() + .expireAfterWrite(timeToIdle) .scheduler(Scheduler.systemScheduler()) .recordStats() .build(); From ea090e9366888121756144d0f490fcc136045a4c Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 14 May 2026 09:07:17 +0200 Subject: [PATCH 12/14] update setting key to trigger rebuild --- .../main/java/org/eclipse/openvsx/settings/SettingsService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java index 7697581aa..2f4f49975 100644 --- a/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java @@ -28,7 +28,7 @@ @Service public class SettingsService { - public static final String SETTING_REGISTRY_READ_ONLY = "registry.read-only"; + public static final String SETTING_REGISTRY_READ_ONLY = "read-only"; private static final String SETTINGS_UPDATE_CHANNEL = "settings.update"; private final Logger logger = LoggerFactory.getLogger(SettingsService.class); From 8e5d38e7ab6dcc2be15e0c7d0e815dd2e72f38b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Thu, 14 May 2026 12:04:34 +0200 Subject: [PATCH 13/14] feat(admin/settings): adding friction on save --- webui/src/pages/admin-dashboard/settings.tsx | 94 ++++++++++++++++---- 1 file changed, 78 insertions(+), 16 deletions(-) diff --git a/webui/src/pages/admin-dashboard/settings.tsx b/webui/src/pages/admin-dashboard/settings.tsx index 101dcce75..498f94181 100644 --- a/webui/src/pages/admin-dashboard/settings.tsx +++ b/webui/src/pages/admin-dashboard/settings.tsx @@ -12,9 +12,17 @@ *****************************************************************************/ import { ChangeEvent, FC, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import CheckIcon from '@mui/icons-material/Check'; +import SaveIcon from '@mui/icons-material/Save'; import { Alert, Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, Paper, Stack, Typography, @@ -27,7 +35,7 @@ import { SettingsItem } from './settings-item'; interface NotificationState { id: string; message: string; - severity: 'success' | 'error'; + severity: 'error'; timeout: ReturnType; } @@ -45,10 +53,14 @@ export const RuntimeSettingsPage: FC = () => { const { service } = useContext(MainContext); const [settings, setSettings] = useState(null); + const [draftSettings, setDraftSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [notifications, setNotifications] = useState([]); + const [confirmOpen, setConfirmOpen] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const saveSuccessTimer = useRef | null>(null); useEffect(() => { return () => abortController.current.abort(); @@ -60,6 +72,7 @@ export const RuntimeSettingsPage: FC = () => { setError(null); const data = await service.admin.getSettings(abortController.current); setSettings(data); + setDraftSettings(data); } catch (err) { setError(handleError(err as Error)); } finally { @@ -75,12 +88,16 @@ export const RuntimeSettingsPage: FC = () => { notifications.forEach(n => clearTimeout(n.timeout)); }, []); - const addNotification = useCallback((notification: Pick) => { + useEffect(() => () => { + if (saveSuccessTimer.current) clearTimeout(saveSuccessTimer.current); + }, []); + + const addNotification = useCallback((notification: Pick) => { const id = crypto.randomUUID(); const timeout = setTimeout(() => { setNotifications(current => current.filter(n => n.id !== id)); }, NOTIFICATION_TIMEOUT); - setNotifications(current => [...current, { ...notification, id, timeout }]); + setNotifications(current => [...current, { ...notification, severity: 'error', id, timeout }]); }, []); const handleNotificationClose = (id: string) => { @@ -91,30 +108,38 @@ export const RuntimeSettingsPage: FC = () => { }); }; - const handleFlagChange = useCallback((key: keyof Settings) => async (_event: ChangeEvent, checked: boolean) => { - if (!settings || saving) return; + const handleFlagChange = useCallback((key: keyof Settings) => (_event: ChangeEvent, checked: boolean) => { + setDraftSettings(current => current ? { ...current, [key]: checked } : current); + }, []); + + const hasChanges = draftSettings !== null && settings !== null && + (Object.keys(SETTINGS) as (keyof Settings)[]).some(k => draftSettings[k] !== settings[k]); + + const handleSaveClick = () => setConfirmOpen(true); - const previousSettings = settings; - const nextSettings: Settings = { ...settings, [key]: checked }; + const handleConfirmClose = () => setConfirmOpen(false); - setSettings(nextSettings); + const handleConfirmSave = useCallback(async () => { + if (!draftSettings) return; + setConfirmOpen(false); setSaving(true); setError(null); try { - const updatedSettings = await service.admin.updateSettings(abortController.current, nextSettings); + const updatedSettings = await service.admin.updateSettings(abortController.current, draftSettings); setSettings(updatedSettings); - addNotification({ severity: 'success', message: 'Runtime settings saved.' }); + setDraftSettings(updatedSettings); + setSaveSuccess(true); + if (saveSuccessTimer.current) clearTimeout(saveSuccessTimer.current); + saveSuccessTimer.current = setTimeout(() => setSaveSuccess(false), 2000); } catch (err) { - setSettings(previousSettings); addNotification({ - severity: 'error', message: `Failed to save runtime settings. ${handleError(err as Error)}`, }); } finally { setSaving(false); } - }, [settings, saving, service, addNotification]); + }, [draftSettings, service, addNotification]); return ( <> @@ -140,15 +165,52 @@ export const RuntimeSettingsPage: FC = () => { key={key} title={flag.title} description={flag.description} - checked={settings?.[key] ?? false} - loading={loading || !settings} - disabled={loading || saving || !settings} + checked={draftSettings?.[key] ?? false} + loading={loading || !draftSettings} + disabled={loading || saving || !draftSettings} onChange={handleFlagChange(key)} /> ))} + + + + + + + Apply settings? + + + These changes will be applied immediately and will affect all users of the registry. + Make sure you understand the impact before proceeding. + + + + + + + + {notifications.length > 0 && ( Date: Thu, 14 May 2026 19:50:37 +0200 Subject: [PATCH 14/14] improve error handling --- webui/src/pages/admin-dashboard/settings.tsx | 7 ++++--- webui/src/utils.ts | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/webui/src/pages/admin-dashboard/settings.tsx b/webui/src/pages/admin-dashboard/settings.tsx index 498f94181..edbad9f4c 100644 --- a/webui/src/pages/admin-dashboard/settings.tsx +++ b/webui/src/pages/admin-dashboard/settings.tsx @@ -78,7 +78,7 @@ export const RuntimeSettingsPage: FC = () => { } finally { setLoading(false); } - }, [service]); + }, [service, error]); useEffect(() => { loadRuntimeSettings(); @@ -110,6 +110,7 @@ export const RuntimeSettingsPage: FC = () => { const handleFlagChange = useCallback((key: keyof Settings) => (_event: ChangeEvent, checked: boolean) => { setDraftSettings(current => current ? { ...current, [key]: checked } : current); + setSaveSuccess(false); }, []); const hasChanges = draftSettings !== null && settings !== null && @@ -159,7 +160,7 @@ export const RuntimeSettingsPage: FC = () => { )} - + {(Object.entries(SETTINGS) as [keyof Settings, { title: string; description: string }][]).map(([key, flag]) => ( {