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/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index b272a706b..9bb21ab19 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.settings.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..febb05d2b 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.settings.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/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 1ea05f8a0..8c31ec31e 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -21,18 +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.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.json.*; +import org.eclipse.openvsx.settings.MutatingOperation; 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; @@ -45,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; @@ -62,6 +48,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; @RestController +@RequestMapping("/admin") @ApiResponse( responseCode = "403", description = "Administration role is required", @@ -71,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; @@ -78,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 @@ -122,7 +112,7 @@ public ResponseEntity getReportJson( } @GetMapping( - path = "/admin/report", + path = "/report", produces = "text/csv" ) @CrossOrigin @@ -146,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() { @@ -164,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) { @@ -193,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( @@ -232,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() { @@ -250,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, @@ -285,11 +275,12 @@ 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 @Operation(summary = "Delete an extension or one or multiple extension versions") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format", @@ -321,9 +312,10 @@ public ResponseEntity deleteExtension( } @PostMapping( - path = "/admin/extension/{namespaceName}/{extensionName}/delete", + path = "/extension/{namespaceName}/{extensionName}/delete", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity deleteExtension( @PathVariable String namespaceName, @PathVariable String extensionName, @@ -339,11 +331,12 @@ 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 @Operation(summary = "Delete a review for an extension by a user") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format", @@ -376,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) { @@ -401,10 +394,11 @@ private String createAdminNamespaceUrl(NamespaceJson namespace) { } @PostMapping( - path = "/admin/create-namespace", + path = "/create-namespace", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity createNamespace(@RequestBody NamespaceJson namespace) { try { admins.checkAdminUser(); @@ -419,9 +413,10 @@ public ResponseEntity createNamespace(@RequestBody NamespaceJson nam } @DeleteMapping( - path = "/admin/namespace/{namespaceName}" + path = "/namespace/{namespaceName}" ) @Operation(summary = "Delete a namespace") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format" @@ -449,10 +444,11 @@ public ResponseEntity deleteNamespace(@PathVariable String namespace } @PostMapping( - path = "/admin/change-namespace", + path = "/change-namespace", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity changeNamespace(@RequestBody ChangeNamespaceJson json) { try { admins.checkAdminUser(); @@ -464,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 @@ -489,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) { @@ -505,11 +501,12 @@ public ResponseEntity getNamespaceMembers(@PathVari } @PostMapping( - path = "/admin/api/namespace/{namespaceName}/change-member", + path = "/api/namespace/{namespaceName}/change-member", produces = MediaType.APPLICATION_JSON_VALUE ) @CrossOrigin @Operation(summary = "Edit a member of a namespace") + @MutatingOperation @ApiResponse( responseCode = "200", description = "A success message is returned in JSON format", @@ -542,9 +539,10 @@ public ResponseEntity editNamespaceMember( } @PostMapping( - path = "/admin/namespace/{namespaceName}/change-member", + path = "/namespace/{namespaceName}/change-member", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity editNamespaceMember( @PathVariable String namespaceName, @RequestParam("user") String userName, @@ -561,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) { @@ -575,9 +573,10 @@ public ResponseEntity getUserPublishInfo(@PathVariable Stri } @PostMapping( - path = "/admin/publisher/{provider}/{loginName}/revoke", + path = "/publisher/{provider}/{loginName}/revoke", produces = MediaType.APPLICATION_JSON_VALUE ) + @MutatingOperation public ResponseEntity revokePublisherContributions(@PathVariable String loginName, @PathVariable String provider) { try { var adminUser = admins.checkAdminUser(); @@ -589,9 +588,10 @@ 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 public ResponseEntity revokePublisherTokens(@PathVariable String loginName, @PathVariable String provider) { try { var adminUser = admins.checkAdminUser(); @@ -601,4 +601,37 @@ 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.getCurrentSettings()); + } 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(); + + var changes = settings.updateFromJson(newSettings); + var json = settings.getCurrentSettings(); + json.setSuccess("Updated settings: " + changes); + 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/admin/FileDecisionAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/FileDecisionAPI.java index 864e5b25a..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,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.settings.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..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,6 +13,7 @@ package org.eclipse.openvsx.admin; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.settings.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..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,6 +22,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.annotation.Nullable; import org.eclipse.openvsx.entities.*; +import org.eclipse.openvsx.settings.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/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index 562dc05aa..dfb09c759 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,37 @@ 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( + @Value("${ovsx.caching.setting.ttl:PT1M}") Duration timeToIdle + ) { + return Caffeine.newBuilder() + .expireAfterWrite(timeToIdle) + .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/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..c609dd01d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/cache/jedis/JedisClusterChannelListener.java @@ -0,0 +1,90 @@ +/****************************************************************************** + * 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(); + } + } + + public abstract void onMessage(String channel, String message); + + 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/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/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/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/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(); 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/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/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(); + } } } diff --git a/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java b/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java new file mode 100644 index 000000000..a0a40d9d4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/settings/MutatingOperation.java @@ -0,0 +1,23 @@ +/****************************************************************************** + * 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 java.lang.annotation.*; + +/** + * A marker annotation to indicate that the annotated operation does database mutations. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MutatingOperation {} diff --git a/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java new file mode 100644 index 000000000..a98b480b4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/settings/ReadOnlyEndpointAspect.java @@ -0,0 +1,42 @@ +/****************************************************************************** + * 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.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 SettingsService settings; + + 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 (settings.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/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 new file mode 100644 index 000000000..2f4f49975 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/settings/SettingsService.java @@ -0,0 +1,107 @@ +/****************************************************************************** + * 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 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; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import redis.clients.jedis.JedisCluster; + +import java.util.ArrayList; + +@Service +public class SettingsService { + + 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); + + private final @Nullable JedisCluster jedisCluster; + private final SettingsUpdateListener settingsUpdateListener; + private final SettingsCache cache; + + public SettingsService(@Nullable JedisCluster jedisCluster, SettingsCache cache) { + this.jedisCluster = jedisCluster; + this.cache = cache; + + if (jedisCluster != null) { + settingsUpdateListener = new SettingsUpdateListener(jedisCluster); + logger.info("SettingsService initialized with Redis update listener"); + } else { + settingsUpdateListener = null; + } + } + + @PostConstruct + public void initialize() { + if (settingsUpdateListener != null) { + settingsUpdateListener.startSubscriber(); + } + } + + @PreDestroy + public void shutdown() { + if (settingsUpdateListener != null) { + settingsUpdateListener.shutdown(); + } + } + + public boolean isReadOnly() { + return cache.getBoolean(SETTING_REGISTRY_READ_ONLY, false); + } + + public SettingsJson getCurrentSettings() { + var json = new SettingsJson(); + json.setReadOnly(isReadOnly()); + return json; + } + + 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 { + SettingsUpdateListener(JedisCluster jedisCluster) { + super(jedisCluster, SETTINGS_UPDATE_CHANNEL, "SettingsUpdate"); + } + + @Override + public void onMessage(String channel, String message) { + if (SETTINGS_UPDATE_CHANNEL.equals(channel)) { + 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 { diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index a01e3191f..4cd16c93a 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, + Settings, } 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>; + getSettings(abortController: AbortController): Promise>; + updateSettings(abortController: AbortController, settings: Settings): Promise>; } export interface AdminServiceConstructor { @@ -1149,6 +1152,34 @@ export class AdminServiceImpl implements AdminService { headers }, false); } + + async getSettings(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'settings']), + }, false); + } + + async updateSettings(abortController: AbortController, settings: Settings): 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: settings, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'settings']), + headers + }, false); + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index cc2d5fc4f..dc12587d4 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 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 4493ca27c..f8837d9d3 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 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 9344b20cd..ad4da7927 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 { RuntimeSettingsPage } from './settings'; 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.SETTINGS, name: 'Settings', icon: , description: 'Manage runtime settings 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/settings-item.tsx b/webui/src/pages/admin-dashboard/settings-item.tsx new file mode 100644 index 000000000..cbec90fc7 --- /dev/null +++ b/webui/src/pages/admin-dashboard/settings-item.tsx @@ -0,0 +1,72 @@ +/****************************************************************************** + * 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, FormGroup, FormControlLabel } 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) ? ( + + ) : ( + + } + label={checked ? "Enabled" : "Disabled"} /> + + )} + + + {description} + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/settings.tsx b/webui/src/pages/admin-dashboard/settings.tsx new file mode 100644 index 000000000..edbad9f4c --- /dev/null +++ b/webui/src/pages/admin-dashboard/settings.tsx @@ -0,0 +1,241 @@ +/****************************************************************************** + * 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 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, +} from '@mui/material'; +import { MainContext } from '../../context'; +import type { Settings } from '../../extension-registry-types'; +import { handleError } from '../../utils'; +import { SettingsItem } from './settings-item'; + +interface NotificationState { + id: string; + message: string; + severity: 'error'; + timeout: ReturnType; +} + +const NOTIFICATION_TIMEOUT = 2000; + +const SETTINGS: Record = { + readOnly: { + title: 'Read-only mode', + description: 'Blocks write operations while keeping browsing, search, and downloads available.', + }, +}; + +export const RuntimeSettingsPage: FC = () => { + const abortController = useRef(new AbortController()); + 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(); + }, []); + + const loadRuntimeSettings = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getSettings(abortController.current); + setSettings(data); + setDraftSettings(data); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, error]); + + useEffect(() => { + loadRuntimeSettings(); + }, [loadRuntimeSettings]); + + useEffect(() => () => { + notifications.forEach(n => clearTimeout(n.timeout)); + }, []); + + 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, severity: 'error', 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 Settings) => (_event: ChangeEvent, checked: boolean) => { + setDraftSettings(current => current ? { ...current, [key]: checked } : current); + setSaveSuccess(false); + }, []); + + const hasChanges = draftSettings !== null && settings !== null && + (Object.keys(SETTINGS) as (keyof Settings)[]).some(k => draftSettings[k] !== settings[k]); + + const handleSaveClick = () => setConfirmOpen(true); + + const handleConfirmClose = () => setConfirmOpen(false); + + const handleConfirmSave = useCallback(async () => { + if (!draftSettings) return; + setConfirmOpen(false); + setSaving(true); + setError(null); + + try { + const updatedSettings = await service.admin.updateSettings(abortController.current, draftSettings); + setSettings(updatedSettings); + setDraftSettings(updatedSettings); + setSaveSuccess(true); + if (saveSuccessTimer.current) clearTimeout(saveSuccessTimer.current); + saveSuccessTimer.current = setTimeout(() => setSaveSuccess(false), 2000); + } catch (err) { + addNotification({ + message: `Failed to save runtime settings. ${handleError(err as Error)}`, + }); + } finally { + setSaving(false); + } + }, [draftSettings, service, addNotification]); + + return ( + <> + + + + Settings + + + Manage runtime settings that apply across the registry. + + + + {error && ( + setError(null)}> + {error} + + )} + + + {(Object.entries(SETTINGS) as [keyof Settings, { title: string; description: string }][]).map(([key, flag]) => ( + + ))} + + + + + + + + + + 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 && ( + 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 diff --git a/webui/src/utils.ts b/webui/src/utils.ts index 4bfa1a29a..dd3de6af3 100644 --- a/webui/src/utils.ts +++ b/webui/src/utils.ts @@ -112,7 +112,12 @@ export function toRelativeTime(timestamp?: string, isFutureTime: boolean = false export function handleError(err?: Error | Partial): string { if (err) { + if (err instanceof Error && err.name === 'AbortError') { + return ''; + } + console.error(err); + if (err instanceof Error) { if (err.message === 'Failed to fetch' || err.message === 'Unexpected token < in JSON at position 0') {