feat: add the ability to install plugins remotely via URI (#3963)

#### What type of PR is this?
/kind feature
/area core
/area console
/milestone 2.6.x
/kind api-change

#### What this PR does / why we need it:
支持通过 URI 远程安装和升级插件

how to test it?
1. 测试插件安装
```shell
curl -u admin:admin -X POST http://localhost:8090/apis/api.console.halo.run/v1alpha1/plugins/-/install-from-uri --data '{
  "uri": "https://halo.run/apis/api.store.halo.run/v1alpha1/applications/app-KhIVw/releases/app-release-canxF/download/app-release-canxF-znFre"
}'
```
2. 测试插件升级
```shell
curl -u admin:admin -X POST http://localhost:8090/apis/api.console.halo.run/v1alpha1/plugins/PluginFeed/upgrade-from-uri --data '{
  "uri": "https://halo.run/apis/api.store.halo.run/v1alpha1/applications/app-KhIVw/releases/app-release-canxF/download/app-release-canxF-znFre"
}'
```
#### Which issue(s) this PR fixes:
Fixes #2292

#### Does this PR introduce a user-facing change?
```release-note
支持通过 URI 远程安装和升级插件
```
pull/4005/head
guqing 2023-05-26 22:54:16 +08:00 committed by GitHub
parent f5493a6d86
commit 710261b035
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 614 additions and 51 deletions

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.endpoint; package run.halo.app.core.extension.endpoint;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static java.util.Comparator.comparing; import static java.util.Comparator.comparing;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; 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.content.Builder.contentBuilder;
@ -17,6 +18,8 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -38,6 +41,7 @@ import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part; import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
@ -56,6 +60,10 @@ import run.halo.app.extension.Comparators;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest.QueryListRequest; import run.halo.app.extension.router.IListRequest.QueryListRequest;
import run.halo.app.infra.ReactiveUrlDataBufferFetcher;
import run.halo.app.infra.exception.ThemeInstallationException;
import run.halo.app.infra.exception.ThemeUpgradeException;
import run.halo.app.infra.utils.DataBufferUtils;
import run.halo.app.plugin.PluginNotFoundException; import run.halo.app.plugin.PluginNotFoundException;
@Slf4j @Slf4j
@ -67,6 +75,8 @@ public class PluginEndpoint implements CustomEndpoint {
private final PluginService pluginService; private final PluginService pluginService;
private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;
@Override @Override
public RouterFunction<ServerResponse> endpoint() { public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.console.halo.run/v1alpha1/Plugin"; final var tag = "api.console.halo.run/v1alpha1/Plugin";
@ -83,6 +93,39 @@ public class PluginEndpoint implements CustomEndpoint {
)) ))
.response(responseBuilder().implementation(Plugin.class)) .response(responseBuilder().implementation(Plugin.class))
) )
.POST("plugins/-/install-from-uri", this::installFromUri,
builder -> builder.operationId("InstallPluginFromUri")
.description("Install a plugin from uri.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(schemaBuilder()
.implementation(InstallFromUriRequest.class))
))
.response(responseBuilder()
.implementation(Plugin.class))
)
.POST("plugins/{name}/upgrade-from-uri", this::upgradeFromUri,
builder -> builder.operationId("UpgradePluginFromUri")
.description("Upgrade a plugin from uri.")
.tag(tag)
.parameter(parameterBuilder()
.in(ParameterIn.PATH)
.name("name")
.required(true)
)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(schemaBuilder()
.implementation(UpgradeFromUriRequest.class))
))
.response(responseBuilder()
.implementation(Plugin.class))
)
.POST("plugins/{name}/upgrade", contentType(MediaType.MULTIPART_FORM_DATA), .POST("plugins/{name}/upgrade", contentType(MediaType.MULTIPART_FORM_DATA),
this::upgrade, builder -> builder.operationId("UpgradePlugin") this::upgrade, builder -> builder.operationId("UpgradePlugin")
.description("Upgrade a plugin by uploading a Jar file") .description("Upgrade a plugin by uploading a Jar file")
@ -177,6 +220,57 @@ public class PluginEndpoint implements CustomEndpoint {
.build(); .build();
} }
private Mono<ServerResponse> upgradeFromUri(ServerRequest request) {
final var name = request.pathVariable("name");
return request.bodyToMono(UpgradeFromUriRequest.class)
.flatMap(upgradeRequest -> Mono.fromCallable(() -> DataBufferUtils.toInputStream(
reactiveUrlDataBufferFetcher.fetch(upgradeRequest.uri())))
)
.subscribeOn(Schedulers.boundedElastic())
.onErrorMap(throwable -> {
log.error("Failed to fetch plugin file from uri.", throwable);
return new ThemeUpgradeException("Failed to fetch plugin file from uri.", null,
null);
})
.flatMap(inputStream -> Mono.usingWhen(
transferToTemp(inputStream),
(path) -> pluginService.upgrade(name, path),
this::deleteFileIfExists)
)
.flatMap(theme -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(theme)
);
}
private Mono<ServerResponse> installFromUri(ServerRequest request) {
return request.bodyToMono(InstallFromUriRequest.class)
.flatMap(installRequest -> Mono.fromCallable(() -> DataBufferUtils.toInputStream(
reactiveUrlDataBufferFetcher.fetch(installRequest.uri())))
)
.subscribeOn(Schedulers.boundedElastic())
.doOnError(throwable -> {
log.error("Failed to fetch plugin file from uri.", throwable);
throw new ThemeInstallationException("Failed to fetch plugin file from uri.", null,
null);
})
.flatMap(inputStream -> Mono.usingWhen(
transferToTemp(inputStream),
pluginService::install,
this::deleteFileIfExists)
)
.flatMap(theme -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(theme)
);
}
public record InstallFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) {
}
public record UpgradeFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) {
}
private Mono<ServerResponse> reload(ServerRequest serverRequest) { private Mono<ServerResponse> reload(ServerRequest serverRequest) {
var name = serverRequest.pathVariable("name"); var name = serverRequest.pathVariable("name");
return ServerResponse.ok().body(pluginService.reload(name), Plugin.class); return ServerResponse.ok().body(pluginService.reload(name), Plugin.class);
@ -508,4 +602,11 @@ public class PluginEndpoint implements CustomEndpoint {
); );
} }
private Mono<Path> transferToTemp(InputStream inputStream) {
return Mono.fromCallable(() -> {
Path tempFile = Files.createTempFile("halo-plugins", ".jar");
FileCopyUtils.copy(inputStream, Files.newOutputStream(tempFile));
return tempFile;
}).subscribeOn(Schedulers.boundedElastic());
}
} }

View File

@ -36,6 +36,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions; import reactor.core.Exceptions;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.retry.Retry; import reactor.util.retry.Retry;
import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.Theme;
@ -247,6 +248,7 @@ public class ThemeEndpoint implements CustomEndpoint {
.flatMap(upgradeRequest -> Mono.fromCallable(() -> DataBufferUtils.toInputStream( .flatMap(upgradeRequest -> Mono.fromCallable(() -> DataBufferUtils.toInputStream(
reactiveUrlDataBufferFetcher.fetch(upgradeRequest.uri()))) reactiveUrlDataBufferFetcher.fetch(upgradeRequest.uri())))
) )
.subscribeOn(Schedulers.boundedElastic())
.doOnError(throwable -> { .doOnError(throwable -> {
log.error("Failed to fetch zip file from uri.", throwable); log.error("Failed to fetch zip file from uri.", throwable);
throw new ThemeUpgradeException("Failed to fetch zip file from uri.", null, throw new ThemeUpgradeException("Failed to fetch zip file from uri.", null,
@ -268,6 +270,7 @@ public class ThemeEndpoint implements CustomEndpoint {
.flatMap(installRequest -> Mono.fromCallable(() -> DataBufferUtils.toInputStream( .flatMap(installRequest -> Mono.fromCallable(() -> DataBufferUtils.toInputStream(
reactiveUrlDataBufferFetcher.fetch(installRequest.uri()))) reactiveUrlDataBufferFetcher.fetch(installRequest.uri())))
) )
.subscribeOn(Schedulers.boundedElastic())
.doOnError(throwable -> { .doOnError(throwable -> {
log.error("Failed to fetch zip file from uri.", throwable); log.error("Failed to fetch zip file from uri.", throwable);
throw new ThemeInstallationException("Failed to fetch zip file from uri.", null, throw new ThemeInstallationException("Failed to fetch zip file from uri.", null,

View File

@ -16,7 +16,8 @@ rules:
resources: [ "plugins" ] resources: [ "plugins" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
- apiGroups: [ "api.console.halo.run" ] - apiGroups: [ "api.console.halo.run" ]
resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config", "plugins/reload" ] resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config", "plugins/reload",
"plugins/install-from-uri", "plugins/upgrade-from-uri" ]
verbs: [ "*" ] verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ] - apiGroups: [ "api.console.halo.run" ]
resources: [ "plugin-presets" ] resources: [ "plugin-presets" ]

View File

@ -40,11 +40,15 @@ import {
// @ts-ignore // @ts-ignore
import { ConfigMap } from "../models"; import { ConfigMap } from "../models";
// @ts-ignore // @ts-ignore
import { InstallFromUriRequest } from "../models";
// @ts-ignore
import { Plugin } from "../models"; import { Plugin } from "../models";
// @ts-ignore // @ts-ignore
import { PluginList } from "../models"; import { PluginList } from "../models";
// @ts-ignore // @ts-ignore
import { Setting } from "../models"; import { Setting } from "../models";
// @ts-ignore
import { UpgradeFromUriRequest } from "../models";
/** /**
* ApiConsoleHaloRunV1alpha1PluginApi - axios parameter creator * ApiConsoleHaloRunV1alpha1PluginApi - axios parameter creator
* @export * @export
@ -231,6 +235,67 @@ export const ApiConsoleHaloRunV1alpha1PluginApiAxiosParamCreator = function (
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
* Install a plugin from uri.
* @param {InstallFromUriRequest} installFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
installPluginFromUri: async (
installFromUriRequest: InstallFromUriRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'installFromUriRequest' is not null or undefined
assertParamExists(
"installPluginFromUri",
"installFromUriRequest",
installFromUriRequest
);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/plugins/-/install-from-uri`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
installFromUriRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* List all plugin presets in the system. * List all plugin presets in the system.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -611,6 +676,75 @@ export const ApiConsoleHaloRunV1alpha1PluginApiAxiosParamCreator = function (
}; };
localVarRequestOptions.data = localVarFormParams; localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Upgrade a plugin from uri.
* @param {string} name
* @param {UpgradeFromUriRequest} upgradeFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
upgradePluginFromUri: async (
name: string,
upgradeFromUriRequest: UpgradeFromUriRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("upgradePluginFromUri", "name", name);
// verify required parameter 'upgradeFromUriRequest' is not null or undefined
assertParamExists(
"upgradePluginFromUri",
"upgradeFromUriRequest",
upgradeFromUriRequest
);
const localVarPath =
`/apis/api.console.halo.run/v1alpha1/plugins/{name}/upgrade-from-uri`.replace(
`{${"name"}}`,
encodeURIComponent(String(name))
);
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
upgradeFromUriRequest,
localVarRequestOptions,
configuration
);
return { return {
url: toPathString(localVarUrlObj), url: toPathString(localVarUrlObj),
options: localVarRequestOptions, options: localVarRequestOptions,
@ -700,6 +834,30 @@ export const ApiConsoleHaloRunV1alpha1PluginApiFp = function (
configuration configuration
); );
}, },
/**
* Install a plugin from uri.
* @param {InstallFromUriRequest} installFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async installPluginFromUri(
installFromUriRequest: InstallFromUriRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Plugin>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.installPluginFromUri(
installFromUriRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/** /**
* List all plugin presets in the system. * List all plugin presets in the system.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -863,6 +1021,33 @@ export const ApiConsoleHaloRunV1alpha1PluginApiFp = function (
configuration configuration
); );
}, },
/**
* Upgrade a plugin from uri.
* @param {string} name
* @param {UpgradeFromUriRequest} upgradeFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async upgradePluginFromUri(
name: string,
upgradeFromUriRequest: UpgradeFromUriRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Plugin>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.upgradePluginFromUri(
name,
upgradeFromUriRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
}; };
}; };
@ -924,6 +1109,20 @@ export const ApiConsoleHaloRunV1alpha1PluginApiFactory = function (
) )
.then((request) => request(axios, basePath)); .then((request) => request(axios, basePath));
}, },
/**
* Install a plugin from uri.
* @param {ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
installPluginFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUriRequest,
options?: AxiosRequestConfig
): AxiosPromise<Plugin> {
return localVarFp
.installPluginFromUri(requestParameters.installFromUriRequest, options)
.then((request) => request(axios, basePath));
},
/** /**
* List all plugin presets in the system. * List all plugin presets in the system.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -1025,6 +1224,24 @@ export const ApiConsoleHaloRunV1alpha1PluginApiFactory = function (
) )
.then((request) => request(axios, basePath)); .then((request) => request(axios, basePath));
}, },
/**
* Upgrade a plugin from uri.
* @param {ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
upgradePluginFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest,
options?: AxiosRequestConfig
): AxiosPromise<Plugin> {
return localVarFp
.upgradePluginFromUri(
requestParameters.name,
requestParameters.upgradeFromUriRequest,
options
)
.then((request) => request(axios, basePath));
},
}; };
}; };
@ -1084,6 +1301,20 @@ export interface ApiConsoleHaloRunV1alpha1PluginApiInstallPluginRequest {
readonly source?: string; readonly source?: string;
} }
/**
* Request parameters for installPluginFromUri operation in ApiConsoleHaloRunV1alpha1PluginApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUriRequest
*/
export interface ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUriRequest {
/**
*
* @type {InstallFromUriRequest}
* @memberof ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUri
*/
readonly installFromUriRequest: InstallFromUriRequest;
}
/** /**
* Request parameters for listPlugins operation in ApiConsoleHaloRunV1alpha1PluginApi. * Request parameters for listPlugins operation in ApiConsoleHaloRunV1alpha1PluginApi.
* @export * @export
@ -1224,6 +1455,27 @@ export interface ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginRequest {
readonly source?: string; readonly source?: string;
} }
/**
* Request parameters for upgradePluginFromUri operation in ApiConsoleHaloRunV1alpha1PluginApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest
*/
export interface ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUri
*/
readonly name: string;
/**
*
* @type {UpgradeFromUriRequest}
* @memberof ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUri
*/
readonly upgradeFromUriRequest: UpgradeFromUriRequest;
}
/** /**
* ApiConsoleHaloRunV1alpha1PluginApi - object-oriented interface * ApiConsoleHaloRunV1alpha1PluginApi - object-oriented interface
* @export * @export
@ -1284,6 +1536,22 @@ export class ApiConsoleHaloRunV1alpha1PluginApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath)); .then((request) => request(this.axios, this.basePath));
} }
/**
* Install a plugin from uri.
* @param {ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PluginApi
*/
public installPluginFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1PluginApiInstallPluginFromUriRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1PluginApiFp(this.configuration)
.installPluginFromUri(requestParameters.installFromUriRequest, options)
.then((request) => request(this.axios, this.basePath));
}
/** /**
* List all plugin presets in the system. * List all plugin presets in the system.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -1394,4 +1662,24 @@ export class ApiConsoleHaloRunV1alpha1PluginApi extends BaseAPI {
) )
.then((request) => request(this.axios, this.basePath)); .then((request) => request(this.axios, this.basePath));
} }
/**
* Upgrade a plugin from uri.
* @param {ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PluginApi
*/
public upgradePluginFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1PluginApiFp(this.configuration)
.upgradePluginFromUri(
requestParameters.name,
requestParameters.upgradeFromUriRequest,
options
)
.then((request) => request(this.axios, this.basePath));
}
} }

View File

@ -733,6 +733,9 @@ core:
change_status: change_status:
active_title: Are you sure you want to active this plugin? active_title: Are you sure you want to active this plugin?
inactive_title: Are you sure you want to inactive this plugin? inactive_title: Are you sure you want to inactive this plugin?
remote_download:
title: Remote download address detected, do you want to download?
description: "Please carefully verify whether this address can be trusted: {url}"
filters: filters:
status: status:
items: items:
@ -750,6 +753,12 @@ core:
titles: titles:
install: Install plugin install: Install plugin
upgrade: Upgrade plugin ({display_name}) upgrade: Upgrade plugin ({display_name})
tabs:
local: Local
remote:
title: Remote
fields:
url: Remote URL
operations: operations:
active_after_install: active_after_install:
title: Install successful title: Install successful

View File

@ -733,6 +733,9 @@ core:
change_status: change_status:
active_title: 确定要启用该插件吗? active_title: 确定要启用该插件吗?
inactive_title: 确定要停用该插件吗? inactive_title: 确定要停用该插件吗?
remote_download:
title: 检测到了远程下载地址,是否需要下载?
description: 请仔细鉴别此地址是否可信:{url}
filters: filters:
status: status:
items: items:
@ -750,6 +753,12 @@ core:
titles: titles:
install: 安装插件 install: 安装插件
upgrade: 升级插件({display_name} upgrade: 升级插件({display_name}
tabs:
local: 本地上传
remote:
title: 远程下载
fields:
url: 下载地址
operations: operations:
active_after_install: active_after_install:
title: 安装成功 title: 安装成功

View File

@ -733,6 +733,9 @@ core:
change_status: change_status:
active_title: 確定要啟用該插件嗎? active_title: 確定要啟用該插件嗎?
inactive_title: 確定要停用該插件嗎? inactive_title: 確定要停用該插件嗎?
remote_download:
title: 偵測到遠端下載地址,是否需要下載?
description: 請仔細鑑別此地址是否可信:{url}
filters: filters:
status: status:
items: items:
@ -750,6 +753,12 @@ core:
titles: titles:
install: 安裝插件 install: 安裝插件
upgrade: 升級插件({display_name} upgrade: 升級插件({display_name}
tabs:
local: 本地上傳
remote:
title: 遠端下載
fields:
url: 下載地址
operations: operations:
active_after_install: active_after_install:
title: 安裝成功 title: 安裝成功

View File

@ -13,10 +13,11 @@ import {
VLoading, VLoading,
VDropdown, VDropdown,
VDropdownItem, VDropdownItem,
Dialog,
} from "@halo-dev/components"; } from "@halo-dev/components";
import PluginListItem from "./components/PluginListItem.vue"; import PluginListItem from "./components/PluginListItem.vue";
import PluginUploadModal from "./components/PluginUploadModal.vue"; import PluginUploadModal from "./components/PluginUploadModal.vue";
import { computed, ref } from "vue"; import { computed, ref, onMounted } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import FilterTag from "@/components/filter/FilterTag.vue"; import FilterTag from "@/components/filter/FilterTag.vue";
@ -25,6 +26,7 @@ import { getNode } from "@formkit/core";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import type { Plugin } from "@halo-dev/api-client"; import type { Plugin } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouteQuery } from "@vueuse/router";
const { t } = useI18n(); const { t } = useI18n();
@ -146,12 +148,34 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
return deletingPlugins?.length ? 3000 : false; return deletingPlugins?.length ? 3000 : false;
}, },
}); });
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
onMounted(() => {
if (routeRemoteDownloadUrl.value) {
Dialog.warning({
title: t("core.plugin.operations.remote_download.title"),
description: t("core.plugin.operations.remote_download.description", {
url: routeRemoteDownloadUrl.value,
}),
confirmText: t("core.common.buttons.download"),
cancelText: t("core.common.buttons.cancel"),
onConfirm() {
pluginInstall.value = true;
},
onCancel() {
routeRemoteDownloadUrl.value = null;
},
});
}
});
</script> </script>
<template> <template>
<PluginUploadModal <PluginUploadModal
v-if="currentUserHasPermission(['system:plugins:manage'])" v-if="currentUserHasPermission(['system:plugins:manage'])"
v-model:visible="pluginInstall" v-model:visible="pluginInstall"
@close="refetch()"
/> />
<VPageHeader :title="$t('core.plugin.title')"> <VPageHeader :title="$t('core.plugin.title')">
@ -324,7 +348,7 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
role="list" role="list"
> >
<li v-for="plugin in data" :key="plugin.metadata.name"> <li v-for="plugin in data" :key="plugin.metadata.name">
<PluginListItem :plugin="plugin" @reload="refetch()" /> <PluginListItem :plugin="plugin" />
</li> </li>
</ul> </ul>
</Transition> </Transition>

View File

@ -32,10 +32,6 @@ const props = withDefaults(
} }
); );
const emit = defineEmits<{
(event: "reload"): void;
}>();
const { plugin } = toRefs(props); const { plugin } = toRefs(props);
const upgradeModal = ref(false); const upgradeModal = ref(false);
@ -43,10 +39,6 @@ const upgradeModal = ref(false);
const { getFailedMessage, changeStatus, uninstall } = const { getFailedMessage, changeStatus, uninstall } =
usePluginLifeCycle(plugin); usePluginLifeCycle(plugin);
const onUpgradeModalClose = () => {
emit("reload");
};
const handleResetSettingConfig = async () => { const handleResetSettingConfig = async () => {
Dialog.warning({ Dialog.warning({
title: t("core.plugin.operations.reset.title"), title: t("core.plugin.operations.reset.title"),
@ -73,11 +65,7 @@ const handleResetSettingConfig = async () => {
}; };
</script> </script>
<template> <template>
<PluginUploadModal <PluginUploadModal v-model:visible="upgradeModal" :upgrade-plugin="plugin" />
v-model:visible="upgradeModal"
:upgrade-plugin="plugin"
@close="onUpgradeModalClose"
/>
<VEntity> <VEntity>
<template #start> <template #start>
<VEntityField> <VEntityField>

View File

@ -1,14 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
import { VModal, Dialog, Toast } from "@halo-dev/components"; import {
VModal,
Dialog,
Toast,
VTabs,
VTabItem,
VButton,
} from "@halo-dev/components";
import UppyUpload from "@/components/upload/UppyUpload.vue"; import UppyUpload from "@/components/upload/UppyUpload.vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { Plugin } from "@halo-dev/api-client"; import type { Plugin } from "@halo-dev/api-client";
import { computed, ref, watch } from "vue"; import { computed, ref, watch, nextTick } from "vue";
import type { SuccessResponse, ErrorResponse } from "@uppy/core"; import type { SuccessResponse, ErrorResponse } from "@uppy/core";
import type { UppyFile } from "@uppy/utils"; import type { UppyFile } from "@uppy/utils";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { submitForm } from "@formkit/core";
const { t } = useI18n(); const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -57,8 +68,13 @@ const onUploaded = async (response: SuccessResponse) => {
return; return;
} }
const plugin = response.body as Plugin;
handleVisibleChange(false); handleVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["plugins"] });
handleShowActiveModalAfterInstall(response.body as Plugin);
};
const handleShowActiveModalAfterInstall = (plugin: Plugin) => {
Dialog.success({ Dialog.success({
title: t("core.plugin.upload_modal.operations.active_after_install.title"), title: t("core.plugin.upload_modal.operations.active_after_install.title"),
description: t( description: t(
@ -103,30 +119,49 @@ const PLUGIN_ALREADY_EXISTS_TYPE =
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => { const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
const body = response.body as PluginInstallationErrorResponse; const body = response.body as PluginInstallationErrorResponse;
if (body.type === PLUGIN_ALREADY_EXISTS_TYPE) { if (body.type === PLUGIN_ALREADY_EXISTS_TYPE) {
Dialog.info({ handleCatchExistsException(body, file.data as File);
title: t(
"core.plugin.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.plugin.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.plugin.upgradePlugin({
name: body.pluginName,
file: file.data as File,
});
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
},
});
} }
}; };
const handleCatchExistsException = async (
error: PluginInstallationErrorResponse,
file?: File
) => {
Dialog.info({
title: t(
"core.plugin.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.plugin.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (activeTabId.value === "local") {
await apiClient.plugin.upgradePlugin({
name: error.pluginName,
file: file,
});
} else if (activeTabId.value === "remote") {
await apiClient.plugin.upgradePluginFromUri({
name: error.pluginName,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
} else {
throw new Error("Unknown tab id");
}
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
},
});
};
watch( watch(
() => props.visible, () => props.visible,
(newValue) => { (newValue) => {
@ -140,24 +175,120 @@ watch(
} }
} }
); );
// remote download
const activeTabId = ref("local");
const remoteDownloadUrl = ref("");
const downloading = ref(false);
const handleDownloadPlugin = async () => {
try {
downloading.value = true;
if (props.upgradePlugin) {
await apiClient.plugin.upgradePluginFromUri({
name: props.upgradePlugin.metadata.name,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
return;
}
const { data: plugin } = await apiClient.plugin.installPluginFromUri({
installFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
handleVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["plugins"] });
handleShowActiveModalAfterInstall(plugin);
// eslint-disable-next-line
} catch (error: any) {
const data = error?.response.data as PluginInstallationErrorResponse;
if (data?.type === PLUGIN_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(data);
}
console.error("Failed to download plugin", error);
} finally {
routeRemoteDownloadUrl.value = null;
downloading.value = false;
}
};
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
watch(
() => props.visible,
(visible) => {
if (routeRemoteDownloadUrl.value && visible) {
activeTabId.value = "remote";
remoteDownloadUrl.value = routeRemoteDownloadUrl.value;
nextTick(() => {
submitForm("plugin-remote-download-form");
});
}
}
);
</script> </script>
<template> <template>
<VModal <VModal
:visible="visible" :visible="visible"
:width="600" :width="600"
:title="modalTitle" :title="modalTitle"
:centered="false"
@update:visible="handleVisibleChange" @update:visible="handleVisibleChange"
> >
<UppyUpload <VTabs v-model:active-id="activeTabId" type="outline" class="!rounded-none">
v-if="uploadVisible" <VTabItem id="local" :label="$t('core.plugin.upload_modal.tabs.local')">
:restrictions="{ <UppyUpload
maxNumberOfFiles: 1, v-if="uploadVisible"
allowedFileTypes: ['.jar'], :restrictions="{
}" maxNumberOfFiles: 1,
:endpoint="endpoint" allowedFileTypes: ['.jar'],
auto-proceed }"
@uploaded="onUploaded" :endpoint="endpoint"
@error="onError" auto-proceed
/> @uploaded="onUploaded"
@error="onError"
/>
</VTabItem>
<VTabItem
id="remote"
:label="$t('core.plugin.upload_modal.tabs.remote.title')"
>
<FormKit
id="plugin-remote-download-form"
name="plugin-remote-download-form"
type="form"
:preserve="true"
@submit="handleDownloadPlugin"
>
<FormKit
v-model="remoteDownloadUrl"
:label="$t('core.plugin.upload_modal.tabs.remote.fields.url')"
type="text"
></FormKit>
</FormKit>
<div class="pt-5">
<VButton
:loading="downloading"
type="secondary"
@click="$formkit.submit('plugin-remote-download-form')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</VTabItem>
</VTabs>
</VModal> </VModal>
</template> </template>