diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index b29521311..4b78ba3dd 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -95,7 +95,7 @@ bucket4j: time: 1 unit: seconds - cache-name: buckets - url: '/vscode/(?!asset/.*/.*/.*/Microsoft.VisualStudio.Services.Icons.Default).*|/api/(?!(.*/.*/review(/delete)?)|(-/(namespace/create|publish))).*' + url: '/vscode/(?!asset/.*/.*/.*/Microsoft.VisualStudio.Services.Icons.Default).*' http-response-headers: Access-Control-Allow-Origin: '*' Access-Control-Expose-Headers: X-Rate-Limit-Retry-After-Seconds, X-Rate-Limit-Remaining @@ -105,7 +105,24 @@ bucket4j: - capacity: 15 time: 1 unit: seconds - + - cache-name: buckets + url: '/api/(?!(.*/.*/review(/delete)?)|(-/(namespace/create|publish))).*' + http-response-headers: + Access-Control-Allow-Origin: '*' + Access-Control-Expose-Headers: X-Rate-Limit-Retry-After-Seconds, X-Rate-Limit-Remaining + rate-limits: + - execute-condition: getParameter("token") != null + expression: getParameter("token") + bandwidths: + - capacity: 15 + time: 1 + unit: seconds + - execute-condition: getParameter("token") == null + expression: getRemoteAddr() + bandwidths: + - capacity: 15 + time: 1 + unit: seconds ovsx: databasesearch: enabled: false diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 1d911463a..3a466e80f 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -408,13 +408,18 @@ private static boolean mismatch(String s1, String s2) { && !s1.equalsIgnoreCase(s2); } - @Transactional(rollbackOn = ErrorResultException.class) - public ResultJson createNamespace(NamespaceJson json, String tokenValue) { + public PersonalAccessToken getAccessToken(String tokenValue) { var token = users.useAccessToken(tokenValue); - if (token == null) { - throw new ErrorResultException("Invalid access token."); + if (token == null || token.getUser() == null) { + throw new ErrorResultException("Invalid access token.", HttpStatus.UNAUTHORIZED); } + return token; + } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson createNamespace(NamespaceJson json, String tokenValue) { + var token = getAccessToken(tokenValue); return createNamespace(json, token.getUser()); } @@ -465,10 +470,7 @@ public ExtensionJson publish(InputStream content, UserData user) throws ErrorRes @Transactional(rollbackOn = ErrorResultException.class) public ExtensionJson publish(InputStream content, String tokenValue) throws ErrorResultException { - var token = users.useAccessToken(tokenValue); - if (token == null || token.getUser() == null) { - throw new ErrorResultException("Invalid access token."); - } + var token = getAccessToken(tokenValue); // Check whether the user has a valid publisher agreement eclipse.checkPublisherAgreement(token.getUser()); diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java index 639d2a0f5..a7ec799a6 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java @@ -86,6 +86,10 @@ protected Iterable getRegistries() { responseCode = "200", description = "The namespace metadata are returned in JSON format" ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified namespace could not be found", @@ -94,8 +98,17 @@ protected Iterable getRegistries() { }) public ResponseEntity getNamespace( @PathVariable @Parameter(description = "Namespace name", example = "redhat") - String namespace + String namespace, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(NamespaceJson.class); + } + } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -120,6 +133,10 @@ public ResponseEntity getNamespace( responseCode = "200", description = "The extension metadata are returned in JSON format" ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified extension could not be found", @@ -130,8 +147,17 @@ public ResponseEntity getExtension( @PathVariable @Parameter(description = "Extension namespace", example = "redhat") String namespace, @PathVariable @Parameter(description = "Extension name", example = "java") - String extension - ) { + String extension, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token + ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(ExtensionJson.class); + } + } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -156,6 +182,10 @@ public ResponseEntity getExtension( responseCode = "200", description = "The extension metadata are returned in JSON format" ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified extension could not be found", @@ -179,8 +209,17 @@ public ResponseEntity getExtension( NAME_WEB, NAME_UNIVERSAL }) ) - CharSequence targetPlatform + CharSequence targetPlatform, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(ExtensionJson.class); + } + } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -205,6 +244,10 @@ public ResponseEntity getExtension( responseCode = "200", description = "The extension metadata are returned in JSON format" ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified extension could not be found", @@ -217,8 +260,17 @@ public ResponseEntity getExtension( @PathVariable @Parameter(description = "Extension name", example = "java") String extension, @PathVariable @Parameter(description = "Extension version", example = "0.65.0") - String version + String version, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(ExtensionJson.class); + } + } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -243,6 +295,10 @@ public ResponseEntity getExtension( responseCode = "200", description = "The extension metadata are returned in JSON format" ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified extension could not be found", @@ -268,8 +324,17 @@ public ResponseEntity getExtension( ) String targetPlatform, @PathVariable @Parameter(description = "Extension version", example = "0.65.0") - String version + String version, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(ExtensionJson.class); + } + } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -301,6 +366,10 @@ public ResponseEntity getExtension( schema = @Schema(type = "string") ) ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified file could not be found", @@ -314,8 +383,17 @@ public ResponseEntity getFile( @PathVariable @Parameter(description = "Extension name", example = "java") String extension, @PathVariable @Parameter(description = "Extension version", example = "0.65.0") - String version + String version, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } var fileName = UrlUtil.extractWildcardPath(request, "/api/{namespace}/{extension}/{version}/file/**"); for (var registry : getRegistries()) { try { @@ -345,6 +423,10 @@ public ResponseEntity getFile( schema = @Schema(type = "string") ) ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified file could not be found", @@ -371,8 +453,17 @@ public ResponseEntity getFile( ) String targetPlatform, @PathVariable @Parameter(description = "Extension version", example = "0.65.0") - String version + String version, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } var fileName = UrlUtil.extractWildcardPath(request, "/api/{namespace}/{extension}/{targetPlatform}/{version}/file/**"); for (var registry : getRegistries()) { try { @@ -395,6 +486,10 @@ public ResponseEntity getFile( responseCode = "200", description = "The reviews are returned in JSON format" ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." + ), @ApiResponse( responseCode = "404", description = "The specified extension could not be found", @@ -405,8 +500,17 @@ public ResponseEntity getReviews( @PathVariable @Parameter(description = "Extension namespace", example = "redhat") String namespace, @PathVariable @Parameter(description = "Extension name", example = "java") - String extension + String extension, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(ReviewListJson.class); + } + } for (var registry : getRegistries()) { try { return ResponseEntity.ok() @@ -438,6 +542,10 @@ public ResponseEntity getReviews( mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = "{\"error\": \"The parameter 'size' must not be negative.\"}") ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." ) }) public ResponseEntity search( @@ -474,8 +582,17 @@ public ResponseEntity search( String sortBy, @RequestParam(required = false) @Parameter(description = "Whether to include information on all available versions for each returned entry") - boolean includeAllVersions + boolean includeAllVersions, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(SearchResultJson.class); + } + } if (size < 0) { var json = SearchResultJson.error("The parameter 'size' must not be negative."); return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); @@ -547,6 +664,10 @@ private int mergeSearchResults(SearchResultJson result, List en mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = "{\"error\":\"The 'extensionId' parameter must have the format 'namespace.extension'.\"}") ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." ) }) public ResponseEntity getQuery( @@ -583,8 +704,18 @@ public ResponseEntity getQuery( NAME_WEB, NAME_UNIVERSAL }) ) - String targetPlatform + String targetPlatform, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(QueryResultJson.class); + } + } + var param = new QueryParamJson(); param.namespaceName = namespaceName; param.extensionName = extensionName; @@ -635,12 +766,26 @@ public ResponseEntity getQuery( mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = "{\"error\":\"The 'extensionId' parameter must have the format 'namespace.extension'.\"}") ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." ) }) public ResponseEntity postQuery( @RequestBody @Parameter(description = "Parameters of the metadata query") - QueryParamJson param + QueryParamJson param, + @RequestParam(required = false) @Parameter(description = "A personal access token") + String token ) { + if(token != null) { + try { + local.getAccessToken(token); + } catch (ErrorResultException e) { + return e.toResponseEntity(QueryResultJson.class); + } + } + var result = new QueryResultJson(); for (var registry : getRegistries()) { try { @@ -689,12 +834,16 @@ public ResponseEntity postQuery( mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = "{ \"error\": \"Invalid access token.\" }") ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." ) }) public ResponseEntity createNamespace( @RequestBody @Parameter(description = "Describes the namespace to create") NamespaceJson namespace, - @RequestParam @Parameter(description = "A personal access token") + @RequestParam @Parameter(description = "A personal access token", required = true) String token ) { if (namespace == null) { @@ -716,51 +865,51 @@ public ResponseEntity createNamespace( } @PostMapping( - path = "/api/user/namespace/create", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE + path = "/api/user/namespace/create", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE ) @Operation( - summary = "Create a namespace", - requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "Describes the namespace to create", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(ref = "NamespaceJson")), - required = true - ) + summary = "Create a namespace", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Describes the namespace to create", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(ref = "NamespaceJson")), + required = true + ) ) @ApiResponses({ - @ApiResponse( - responseCode = "201", - description = "Successfully created the namespace", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value="{ \"success\": \"Created namespace foobar\" }") - ), - headers = @Header( - name = "Location", - description = "The URL of the namespace metadata", - schema = @Schema(type = "string") - ) - ), - @ApiResponse( - responseCode = "400", - description = "The namespace could not be created", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value="{ \"error\": \"Invalid access token.\" }") - ) + @ApiResponse( + responseCode = "201", + description = "Successfully created the namespace", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value="{ \"success\": \"Created namespace foobar\" }") ), - @ApiResponse( - responseCode = "403", - description = "User is not logged in" + headers = @Header( + name = "Location", + description = "The URL of the namespace metadata", + schema = @Schema(type = "string") ) + ), + @ApiResponse( + responseCode = "400", + description = "The namespace could not be created", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value="{ \"error\": \"Invalid access token.\" }") + ) + ), + @ApiResponse( + responseCode = "401", + description = "User is not logged in" + ) }) public ResponseEntity createNamespace( @RequestBody NamespaceJson namespace ) { var user = users.findLoggedInUser(); if (user == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } if (namespace == null) { @@ -811,11 +960,15 @@ public ResponseEntity createNamespace( mediaType = MediaType.APPLICATION_JSON_VALUE, examples = @ExampleObject(value = "{ \"error\": \"Invalid access token.\" }") ) + ), + @ApiResponse( + responseCode = "401", + description = "Invalid access token." ) }) public ResponseEntity publish( InputStream content, - @RequestParam @Parameter(description = "A personal access token") String token + @RequestParam @Parameter(description = "A personal access token", required = true) String token ) { try { var json = local.publish(content, token); @@ -830,40 +983,40 @@ public ResponseEntity publish( } @PostMapping( - path = "/api/user/publish", - consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE + path = "/api/user/publish", + consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE ) @Operation( - summary = "Publish an extension by uploading a vsix file", - requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "Uploaded vsix file to publish", - content = @Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, schema = @Schema(type = "string", format = "binary")), - required = true - ) + summary = "Publish an extension by uploading a vsix file", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Uploaded vsix file to publish", + content = @Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, schema = @Schema(type = "string", format = "binary")), + required = true + ) ) @ApiResponses({ - @ApiResponse( - responseCode = "201", - description = "Successfully published the extension", - headers = @Header( - name = "Location", - description = "The URL of the extension metadata", - schema = @Schema(type = "string") - ) - ), - @ApiResponse( - responseCode = "400", - description = "The extension could not be published", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - examples = @ExampleObject(value="{ \"error\": \"Unknown publisher: foobar\" }") - ) - ), - @ApiResponse( - responseCode = "403", - description = "User is not logged in" + @ApiResponse( + responseCode = "201", + description = "Successfully published the extension", + headers = @Header( + name = "Location", + description = "The URL of the extension metadata", + schema = @Schema(type = "string") + ) + ), + @ApiResponse( + responseCode = "400", + description = "The extension could not be published", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value="{ \"error\": \"Unknown publisher: foobar\" }") ) + ), + @ApiResponse( + responseCode = "401", + description = "User is not logged in" + ) }) public ResponseEntity publish( InputStream content @@ -871,7 +1024,7 @@ public ResponseEntity publish( try { var user = users.findLoggedInUser(); if (user == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } var json = local.publish(content, user); @@ -932,5 +1085,4 @@ public ResponseEntity deleteReview(@PathVariable String namespace, return new ResponseEntity<>(json, HttpStatus.BAD_REQUEST); } } - } \ No newline at end of file diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 176622e81..aa0deade6 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -795,7 +795,7 @@ public void testCreateNamespaceInactiveToken() throws Exception { mockMvc.perform(post("/api/-/namespace/create?token={token}", "my_token") .contentType(MediaType.APPLICATION_JSON) .content(namespaceJson(n -> { n.name = "foobar"; }))) - .andExpect(status().isBadRequest()) + .andExpect(status().isUnauthorized()) .andExpect(content().json(errorJson("Invalid access token."))); } @@ -875,7 +875,7 @@ public void testPublishInactiveToken() throws Exception { mockMvc.perform(post("/api/-/publish?token={token}", "my_token") .contentType(MediaType.APPLICATION_OCTET_STREAM) .content(bytes)) - .andExpect(status().isBadRequest()) + .andExpect(status().isUnauthorized()) .andExpect(content().json(errorJson("Invalid access token."))); }