Provide an endpoint to install plugin using Jar file (#2271)

#### What type of PR is this?

/kind feature
/area core
/milestone 2.0

#### What this PR does / why we need it:

Provide an endpoint to install plugin using Jar file.

#### Special notes for your reviewer:

Currently, you could login and open the swagger ui to test against this feature.

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/2272/head
John Niang 2022-07-22 18:11:51 +08:00 committed by GitHub
parent 4cb94d3752
commit 0b4b1c321b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 126 additions and 23 deletions

View File

@ -0,0 +1,117 @@
package run.halo.app.core.extension.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.YamlPluginFinder;
@Slf4j
@Component
public class PluginEndpoint implements CustomEndpoint {
private final PluginProperties pluginProperties;
private final ExtensionClient client;
public PluginEndpoint(PluginProperties pluginProperties, ExtensionClient client) {
this.pluginProperties = pluginProperties;
this.client = client;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.halo.run/v1alpha1/Plugin";
return SpringdocRouteBuilder.route()
.POST("plugins/install", contentType(MediaType.MULTIPART_FORM_DATA),
this::install, builder -> builder.operationId("InstallPlugin")
.description("Install a plugin by uploading a Jar file.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
.schema(Builder.schemaBuilder().implementation(InstallRequest.class))
))
.response(responseBuilder())
)
.build();
}
public record InstallRequest(
@Schema(required = true, description = "Plugin Jar file.") FilePart file) {
}
Mono<ServerResponse> install(ServerRequest request) {
return request.bodyToMono(new ParameterizedTypeReference<MultiValueMap<String, Part>>() {
})
.flatMap(this::getJarFilePart)
.flatMap(file -> {
var pluginRoot = Paths.get(pluginProperties.getPluginsRoot());
createDirectoriesIfNotExists(pluginRoot);
var pluginPath = pluginRoot.resolve(file.filename());
return file.transferTo(pluginPath).thenReturn(pluginPath);
}).map(pluginPath -> {
log.info("Plugin uploaded at {}", pluginPath);
var plugin = new YamlPluginFinder().find(pluginPath);
// overwrite the enabled flag
plugin.getSpec().setEnabled(false);
var createdPlugin =
client.fetch(Plugin.class, plugin.getMetadata().getName()).orElseGet(() -> {
client.create(plugin);
return client.fetch(Plugin.class, plugin.getMetadata().getName())
.orElseThrow();
});
return createdPlugin;
}).flatMap(plugin -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(plugin));
}
void createDirectoriesIfNotExists(Path directory) {
if (Files.exists(directory)) {
return;
}
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new RuntimeException("Failed to create directory " + directory, e);
}
}
Mono<FilePart> getJarFilePart(MultiValueMap<String, Part> formData) {
Part part = formData.getFirst("file");
if (!(part instanceof FilePart file)) {
return Mono.error(new ServerWebInputException(
"Invalid parameter of file, binary data is required"));
}
if (!Paths.get(file.filename()).toString().endsWith(".jar")) {
return Mono.error(new ServerWebInputException(
"Invalid file type, only jar is supported"));
}
return Mono.just(file);
}
}

View File

@ -1,5 +1,6 @@
package run.halo.app.infra.properties; package run.halo.app.infra.properties;
import java.nio.file.Path;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import lombok.Data; import lombok.Data;
@ -13,6 +14,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "halo") @ConfigurationProperties(prefix = "halo")
public class HaloProperties { public class HaloProperties {
private Path workDir;
private Set<String> initialExtensionLocations = new HashSet<>(); private Set<String> initialExtensionLocations = new HashSet<>();
private final ExtensionProperties extension = new ExtensionProperties(); private final ExtensionProperties extension = new ExtensionProperties();

View File

@ -57,7 +57,7 @@ public class PluginProperties {
* Plugin root directory: default plugins; when non-jar mode plugin, the value should be an * Plugin root directory: default plugins; when non-jar mode plugin, the value should be an
* absolute directory address. * absolute directory address.
*/ */
private String pluginsRoot = "plugins"; private String pluginsRoot;
/** /**
* Allows providing custom plugin loaders. * Allows providing custom plugin loaders.

View File

@ -1,32 +1,13 @@
server: server:
port: 8090 port: 8090
spring: spring:
output: output:
ansi: ansi:
enabled: always enabled: always
datasource:
type: com.zaxxer.hikari.HikariDataSource
# H2 database configuration.
driver-class-name: org.h2.Driver
url: jdbc:h2:file:~/halo-next/db/halo;AUTO_SERVER=TRUE
username: admin
password: 123456
jpa: jpa:
hibernate:
ddl-auto: update
open-in-view: false
show-sql: true show-sql: true
security:
oauth2:
client:
registration:
google:
client-id: google-client-id
client-secret: google-client-secret
halo: halo:
security: security:
oauth2: oauth2:
@ -41,7 +22,6 @@ halo:
- "build/resources" - "build/resources"
lib-directories: lib-directories:
- "libs" - "libs"
plugins-root: plugins
logging: logging:
level: level:
run.halo.app: DEBUG run.halo.app: DEBUG

View File

@ -9,7 +9,7 @@ spring:
# H2 database configuration. # H2 database configuration.
driver-class-name: org.h2.Driver driver-class-name: org.h2.Driver
url: jdbc:h2:file:~/halo-next/db/halo;AUTO_SERVER=TRUE url: jdbc:h2:file:${halo.work-dir}/db/halo;AUTO_SERVER=TRUE
username: admin username: admin
password: 123456 password: 123456
@ -35,6 +35,9 @@ halo:
jwt: jwt:
public-key-location: classpath:app.pub public-key-location: classpath:app.pub
private-key-location: classpath:app.key private-key-location: classpath:app.key
work-dir: ${user.home}/halo-next
plugin:
plugins-root: ${halo.work-dir}/plugins
springdoc: springdoc:
api-docs: api-docs: