feat: add the ability to install themes remotely via URI (#3939)

#### What type of PR is this?
/kind improvement
/area core
/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/themes/-/install-from-uri --data '{
  "uri": "https://halo.run/apis/api.store.halo.run/v1alpha1/applications/app-eiTyL/releases/app-release-QSyjc/download/app-release-QSyjc-JOSOB"
}'
```
2. 测试主题升级
```shell
curl -u admin:admin -X POST http://localhost:8090/apis/api.console.halo.run/v1alpha1/themes/guqing-higan/upgrade-from-uri --data '{
  "uri": "https://halo.run/apis/api.store.halo.run/v1alpha1/applications/app-eiTyL/releases/app-release-QSyjc/download/app-release-QSyjc-JOSOB"
}'
```
#### Which issue(s) this PR fixes:

Fixes #2291

#### Does this PR introduce a user-facing change?
```release-note
支持通过 URI 远程安装和升级主题
```

---------
Co-authored-by: Ryan Wang <i@ryanc.cc>
pull/3973/head
guqing 2023-05-19 10:10:24 +08:00 committed by GitHub
parent 8deea08231
commit 170cf4e412
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 763 additions and 18 deletions

View File

@ -1,5 +1,6 @@
package run.halo.app.core.extension.theme;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
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.parameter.Builder.parameterBuilder;
@ -11,6 +12,7 @@ import static run.halo.app.infra.utils.DataBufferUtils.toInputStream;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
@ -43,10 +45,14 @@ import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.QueryParamBuildUtil;
import run.halo.app.infra.ReactiveUrlDataBufferFetcher;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.infra.exception.NotFoundException;
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.infra.utils.JsonUtils;
import run.halo.app.theme.TemplateEngineManager;
@ -71,6 +77,8 @@ public class ThemeEndpoint implements CustomEndpoint {
private final SystemConfigurableEnvironmentFetcher systemEnvironmentFetcher;
private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;
@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.console.halo.run/v1alpha1/Theme";
@ -89,6 +97,39 @@ public class ThemeEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(Theme.class))
)
.POST("themes/-/install-from-uri", this::installFromUri,
builder -> builder.operationId("InstallThemeFromUri")
.description("Install a theme from uri.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(schemaBuilder()
.implementation(InstallFromUriRequest.class))
))
.response(responseBuilder()
.implementation(Theme.class))
)
.POST("themes/{name}/upgrade-from-uri", this::upgradeFromUri,
builder -> builder.operationId("UpgradeThemeFromUri")
.description("Upgrade a theme 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(Theme.class))
)
.POST("themes/{name}/upgrade", this::upgrade,
builder -> builder.operationId("UpgradeTheme")
.description("Upgrade theme")
@ -200,6 +241,45 @@ public class ThemeEndpoint implements CustomEndpoint {
.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())))
)
.doOnError(throwable -> {
log.error("Failed to fetch zip file from uri.", throwable);
throw new ThemeUpgradeException("Failed to fetch zip file from uri.", null,
null);
})
.flatMap(inputStream -> themeService.upgrade(name, inputStream))
.flatMap((updatedTheme) -> templateEngineManager.clearCache(
updatedTheme.getMetadata().getName())
.thenReturn(updatedTheme)
)
.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())))
)
.doOnError(throwable -> {
log.error("Failed to fetch zip file from uri.", throwable);
throw new ThemeInstallationException("Failed to fetch zip file from uri.", null,
null);
})
.flatMap(themeService::install)
.flatMap(theme -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(theme)
);
}
private Mono<ServerResponse> activateTheme(ServerRequest request) {
final var activatedThemeName = request.pathVariable("name");
return client.fetch(Theme.class, activatedThemeName)
@ -332,6 +412,9 @@ public class ThemeEndpoint implements CustomEndpoint {
}
public record UpgradeFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) {
}
public static class UpgradeRequest implements IUpgradeRequest {
private final MultiValueMap<String, Part> multipartData;
@ -418,6 +501,9 @@ public class ThemeEndpoint implements CustomEndpoint {
@Schema(required = true, description = "Theme zip file.") FilePart file) {
}
public record InstallFromUriRequest(@Schema(requiredMode = REQUIRED) URI uri) {
}
Mono<ServerResponse> install(ServerRequest request) {
return request.body(BodyExtractors.toMultipartData())
.flatMap(this::getZipFilePart)

View File

@ -19,20 +19,25 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.BaseStream;
import java.util.stream.Stream;
import java.util.zip.ZipInputStream;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.exception.ThemeAlreadyExistsException;
import run.halo.app.infra.exception.ThemeInstallationException;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
@Slf4j
class ThemeUtils {
private static final String THEME_TMP_PREFIX = "halo-theme-";
private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"};
@ -97,7 +102,11 @@ class ThemeUtils {
}
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir) {
return unzipThemeTo(inputStream, themeWorkDir, false);
return unzipThemeTo(inputStream, themeWorkDir, false)
.onErrorMap(e -> !(e instanceof ResponseStatusException), e -> {
log.error("Failed to unzip theme", e);
throw new ServerWebInputException("Failed to unzip theme");
});
}
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir,
@ -130,8 +139,7 @@ class ThemeUtils {
var themeTargetPath = themeWorkDir.resolve(themeName);
try {
if (!override && !FileUtils.isEmpty(themeTargetPath)) {
throw new ThemeInstallationException("Theme already exists.",
"problemDetail.theme.install.alreadyExists", new Object[] {themeName});
throw new ThemeAlreadyExistsException(themeName);
}
// install theme to theme work dir
copyRecursively(themeManifestPath.getParent(), themeTargetPath);
@ -141,7 +149,10 @@ class ThemeUtils {
throw Exceptions.propagate(e);
}
})
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir.get()));
.doFinally(signalType -> {
FileUtils.closeQuietly(inputStream);
deleteRecursivelyAndSilently(tempDir.get());
});
}
static Unstructured loadThemeManifest(Path themeManifestPath) {

View File

@ -0,0 +1,34 @@
package run.halo.app.infra;
import java.net.URI;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.netty.http.client.HttpClient;
/**
* <p>A default implementation of {@link ReactiveUrlDataBufferFetcher}.</p>
*
* @author guqing
* @since 2.6.0
*/
@Component
public class DefaultReactiveUrlDataBufferFetcher implements ReactiveUrlDataBufferFetcher {
private final HttpClient httpClient = HttpClient.create()
.followRedirect(true);
private final WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
@Override
public Flux<DataBuffer> fetch(URI uri) {
return webClient.get()
.uri(uri)
.accept(MediaType.APPLICATION_OCTET_STREAM)
.retrieve()
.bodyToFlux(DataBuffer.class);
}
}

View File

@ -0,0 +1,23 @@
package run.halo.app.infra;
import java.net.URI;
import org.springframework.core.io.buffer.DataBuffer;
import reactor.core.publisher.Flux;
/**
* <p>{@link DataBuffer} stream fetcher from uri.</p>
*
* @author guqing
* @since 2.6.0
*/
@FunctionalInterface
public interface ReactiveUrlDataBufferFetcher {
/**
* <p>Fetch data buffer flux from uri.</p>
*
* @param uri uri to fetch
* @return data buffer flux
*/
Flux<DataBuffer> fetch(URI uri);
}

View File

@ -0,0 +1,29 @@
package run.halo.app.infra.exception;
import java.net.URI;
import org.springframework.lang.NonNull;
import org.springframework.web.server.ServerWebInputException;
/**
* {@link ThemeAlreadyExistsException} indicates the provided theme has already installed before.
*
* @author guqing
* @since 2.6.0
*/
public class ThemeAlreadyExistsException extends ServerWebInputException {
public static final String THEME_ALREADY_EXISTS_TYPE =
"https://halo.run/probs/theme-alreay-exists";
/**
* Constructs a {@code ThemeAlreadyExistsException} with the given theme name.
*
* @param themeName theme name must not be blank
*/
public ThemeAlreadyExistsException(@NonNull String themeName) {
super("Theme already exists.", null, null, "problemDetail.theme.install.alreadyExists",
new Object[] {themeName});
setType(URI.create(THEME_ALREADY_EXISTS_TYPE));
getBody().setProperty("themeName", themeName);
}
}

View File

@ -15,7 +15,8 @@ rules:
resources: [ "themes" ]
verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "themes", "themes/reload", "themes/resetconfig", "themes/config", "themes/activation" ]
resources: [ "themes", "themes/reload", "themes/resetconfig", "themes/config", "themes/activation",
"themes/install-from-uri", "themes/upgrade-from-uri" ]
verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ]
verbs: [ "create" ]

View File

@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -12,6 +13,7 @@ import static org.springframework.web.reactive.function.BodyInserters.fromMultip
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
@ -23,18 +25,21 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ReactiveUrlDataBufferFetcher;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ThemeRootGetter;
@ -64,6 +69,9 @@ class ThemeEndpointTest {
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
@Mock
private ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher;
@InjectMocks
ThemeEndpoint themeEndpoint;
@ -138,6 +146,32 @@ class ThemeEndpointTest {
verify(templateEngineManager, times(1)).clearCache(eq("default"));
}
@Test
void upgradeFromUri() {
final URI uri = URI.create("https://example.com/test-theme.zip");
Theme fakeTheme = mock(Theme.class);
Metadata metadata = new Metadata();
metadata.setName("default");
when(fakeTheme.getMetadata()).thenReturn(metadata);
when(themeService.upgrade(eq("default"), isA(InputStream.class)))
.thenReturn(Mono.just(fakeTheme));
when(reactiveUrlDataBufferFetcher.fetch(eq(uri)))
.thenReturn(Flux.just(mock(DataBuffer.class)));
when(templateEngineManager.clearCache(eq("default")))
.thenReturn(Mono.empty());
var body = new ThemeEndpoint.UpgradeFromUriRequest(uri);
webTestClient.post()
.uri("/themes/default/upgrade-from-uri")
.bodyValue(body)
.exchange()
.expectStatus().isOk();
verify(themeService).upgrade(eq("default"), isA(InputStream.class));
verify(templateEngineManager, times(1)).clearCache(eq("default"));
verify(reactiveUrlDataBufferFetcher).fetch(eq(uri));
}
}
@Test
@ -173,6 +207,25 @@ class ThemeEndpointTest {
.expectStatus().is5xxServerError();
}
@Test
void installFromUri() {
final URI uri = URI.create("https://example.com/test-theme.zip");
Theme fakeTheme = mock(Theme.class);
when(themeService.install(isA(InputStream.class)))
.thenReturn(Mono.just(fakeTheme));
when(reactiveUrlDataBufferFetcher.fetch(eq(uri)))
.thenReturn(Flux.just(mock(DataBuffer.class)));
var body = new ThemeEndpoint.UpgradeFromUriRequest(uri);
webTestClient.post()
.uri("/themes/-/install-from-uri")
.bodyValue(body)
.exchange()
.expectStatus().isOk();
verify(themeService).install(isA(InputStream.class));
verify(reactiveUrlDataBufferFetcher).fetch(eq(uri));
}
@Test
void reloadTheme() {
when(themeService.reloadTheme(any())).thenReturn(Mono.empty());

View File

@ -120,6 +120,7 @@ models/group-spec.ts
models/group-status.ts
models/group.ts
models/index.ts
models/install-from-uri-request.ts
models/license.ts
models/listed-auth-provider.ts
models/listed-comment-list.ts
@ -222,6 +223,7 @@ models/theme-list.ts
models/theme-spec.ts
models/theme-status.ts
models/theme.ts
models/upgrade-from-uri-request.ts
models/user-connection-list.ts
models/user-connection-spec.ts
models/user-connection.ts

View File

@ -40,11 +40,15 @@ import {
// @ts-ignore
import { ConfigMap } from "../models";
// @ts-ignore
import { InstallFromUriRequest } from "../models";
// @ts-ignore
import { Setting } from "../models";
// @ts-ignore
import { Theme } from "../models";
// @ts-ignore
import { ThemeList } from "../models";
// @ts-ignore
import { UpgradeFromUriRequest } from "../models";
/**
* ApiConsoleHaloRunV1alpha1ThemeApi - axios parameter creator
* @export
@ -321,6 +325,67 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiAxiosParamCreator = function (
options: localVarRequestOptions,
};
},
/**
* Install a theme from uri.
* @param {InstallFromUriRequest} installFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
installThemeFromUri: async (
installFromUriRequest: InstallFromUriRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'installFromUriRequest' is not null or undefined
assertParamExists(
"installThemeFromUri",
"installFromUriRequest",
installFromUriRequest
);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/themes/-/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 themes.
* @param {boolean} uninstalled
@ -635,6 +700,75 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiAxiosParamCreator = function (
};
localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Upgrade a theme from uri.
* @param {string} name
* @param {UpgradeFromUriRequest} upgradeFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
upgradeThemeFromUri: async (
name: string,
upgradeFromUriRequest: UpgradeFromUriRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("upgradeThemeFromUri", "name", name);
// verify required parameter 'upgradeFromUriRequest' is not null or undefined
assertParamExists(
"upgradeThemeFromUri",
"upgradeFromUriRequest",
upgradeFromUriRequest
);
const localVarPath =
`/apis/api.console.halo.run/v1alpha1/themes/{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 {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -760,6 +894,30 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiFp = function (
configuration
);
},
/**
* Install a theme from uri.
* @param {InstallFromUriRequest} installFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async installThemeFromUri(
installFromUriRequest: InstallFromUriRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Theme>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.installThemeFromUri(
installFromUriRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* List themes.
* @param {boolean} uninstalled
@ -892,6 +1050,33 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiFp = function (
configuration
);
},
/**
* Upgrade a theme from uri.
* @param {string} name
* @param {UpgradeFromUriRequest} upgradeFromUriRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async upgradeThemeFromUri(
name: string,
upgradeFromUriRequest: UpgradeFromUriRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Theme>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.upgradeThemeFromUri(
name,
upgradeFromUriRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
@ -972,6 +1157,20 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiFactory = function (
.installTheme(requestParameters.file, options)
.then((request) => request(axios, basePath));
},
/**
* Install a theme from uri.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
installThemeFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUriRequest,
options?: AxiosRequestConfig
): AxiosPromise<Theme> {
return localVarFp
.installThemeFromUri(requestParameters.installFromUriRequest, options)
.then((request) => request(axios, basePath));
},
/**
* List themes.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiListThemesRequest} requestParameters Request parameters.
@ -1053,6 +1252,24 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiFactory = function (
.upgradeTheme(requestParameters.name, requestParameters.file, options)
.then((request) => request(axios, basePath));
},
/**
* Upgrade a theme from uri.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
upgradeThemeFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUriRequest,
options?: AxiosRequestConfig
): AxiosPromise<Theme> {
return localVarFp
.upgradeThemeFromUri(
requestParameters.name,
requestParameters.upgradeFromUriRequest,
options
)
.then((request) => request(axios, basePath));
},
};
};
@ -1112,6 +1329,20 @@ export interface ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest {
readonly file: File;
}
/**
* Request parameters for installThemeFromUri operation in ApiConsoleHaloRunV1alpha1ThemeApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUriRequest
*/
export interface ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUriRequest {
/**
*
* @type {InstallFromUriRequest}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUri
*/
readonly installFromUriRequest: InstallFromUriRequest;
}
/**
* Request parameters for listThemes operation in ApiConsoleHaloRunV1alpha1ThemeApi.
* @export
@ -1224,6 +1455,27 @@ export interface ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeRequest {
readonly file: File;
}
/**
* Request parameters for upgradeThemeFromUri operation in ApiConsoleHaloRunV1alpha1ThemeApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUriRequest
*/
export interface ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUriRequest {
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUri
*/
readonly name: string;
/**
*
* @type {UpgradeFromUriRequest}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUri
*/
readonly upgradeFromUriRequest: UpgradeFromUriRequest;
}
/**
* ApiConsoleHaloRunV1alpha1ThemeApi - object-oriented interface
* @export
@ -1307,6 +1559,22 @@ export class ApiConsoleHaloRunV1alpha1ThemeApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath));
}
/**
* Install a theme from uri.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApi
*/
public installThemeFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeFromUriRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1ThemeApiFp(this.configuration)
.installThemeFromUri(requestParameters.installFromUriRequest, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* List themes.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiListThemesRequest} requestParameters Request parameters.
@ -1397,4 +1665,24 @@ export class ApiConsoleHaloRunV1alpha1ThemeApi extends BaseAPI {
.upgradeTheme(requestParameters.name, requestParameters.file, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Upgrade a theme from uri.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUriRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApi
*/
public upgradeThemeFromUri(
requestParameters: ApiConsoleHaloRunV1alpha1ThemeApiUpgradeThemeFromUriRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1ThemeApiFp(this.configuration)
.upgradeThemeFromUri(
requestParameters.name,
requestParameters.upgradeFromUriRequest,
options
)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -58,6 +58,7 @@ export * from "./group-kind";
export * from "./group-list";
export * from "./group-spec";
export * from "./group-status";
export * from "./install-from-uri-request";
export * from "./license";
export * from "./listed-auth-provider";
export * from "./listed-comment";
@ -160,6 +161,7 @@ export * from "./theme";
export * from "./theme-list";
export * from "./theme-spec";
export * from "./theme-status";
export * from "./upgrade-from-uri-request";
export * from "./user";
export * from "./user-connection";
export * from "./user-connection-list";

View File

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface InstallFromUriRequest
*/
export interface InstallFromUriRequest {
/**
*
* @type {string}
* @memberof InstallFromUriRequest
*/
uri: string;
}

View File

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface UpgradeFromUriRequest
*/
export interface UpgradeFromUriRequest {
/**
*
* @type {string}
* @memberof UpgradeFromUriRequest
*/
uri: string;
}

View File

@ -597,10 +597,19 @@ core:
uninstall_and_delete_config:
button: Uninstall and delete config
title: Are you sure you want to uninstall this theme and its corresponding settings?
remote_download:
title: Remote download address detected, do you want to download?
description: "Please carefully verify whether this address can be trusted: {url}"
upload_modal:
titles:
install: Install theme
upgrade: Upgrade theme ({display_name})
tabs:
local: Local
remote:
title: Remote
fields:
url: Remote URL
list_modal:
titles:
installed_themes: Installed Themes

View File

@ -597,10 +597,19 @@ core:
uninstall_and_delete_config:
button: 卸载并删除配置
title: 确定要卸载该主题以及对应的配置吗?
remote_download:
title: 检测到了远程下载地址,是否需要下载?
description: 请仔细鉴别此地址是否可信:{url}
upload_modal:
titles:
install: 安装主题
upgrade: 升级主题({display_name}
tabs:
local: 本地上传
remote:
title: 远程下载
fields:
url: 下载地址
list_modal:
titles:
installed_themes: 已安装的主题

View File

@ -597,10 +597,19 @@ core:
uninstall_and_delete_config:
button: 卸載並刪除配置
title: 確定要卸載該主題以及對應的配置嗎?
remote_download:
title: 偵測到遠端下載地址,是否需要下載?
description: 請仔細鑑別此地址是否可信:{url}
upload_modal:
titles:
install: 安裝主題
upgrade: 升級主題({display_name}
tabs:
local: 本地上傳
remote:
title: 遠端下載
fields:
url: 下載地址
list_modal:
titles:
installed_themes: 已安裝的主題

View File

@ -17,11 +17,12 @@ import LazyImage from "@/components/image/LazyImage.vue";
import ThemePreviewModal from "./preview/ThemePreviewModal.vue";
import ThemeUploadModal from "./ThemeUploadModal.vue";
import ThemeListItem from "./components/ThemeListItem.vue";
import { computed, ref } from "vue";
import { computed, ref, nextTick, watch } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
const { t } = useI18n();
@ -143,6 +144,19 @@ const handleOpenInstallModal = () => {
themeToUpgrade.value = undefined;
themeUploadVisible.value = true;
};
// handle remote wordpress url from route
const remoteDownloadUrl = useRouteQuery<string>("remote-download-url");
watch(
() => props.visible,
(visible) => {
if (visible && remoteDownloadUrl.value) {
nextTick(() => {
handleOpenInstallModal();
});
}
}
);
</script>
<template>
<VModal

View File

@ -1,11 +1,14 @@
<script lang="ts" setup>
import { Toast, VModal } from "@halo-dev/components";
import { Toast, VButton, VModal, VTabItem, VTabs } from "@halo-dev/components";
import UppyUpload from "@/components/upload/UppyUpload.vue";
import { computed, ref, watch } from "vue";
import { computed, ref, watch, nextTick } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import { useThemeStore } from "@/stores/theme";
import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
import { submitForm } from "@formkit/core";
const { t } = useI18n();
const queryClient = useQueryClient();
@ -79,23 +82,118 @@ const onUploaded = () => {
handleVisibleChange(false);
};
// remote download
const activeTabId = ref("local");
const remoteDownloadUrl = ref("");
const downloading = ref(false);
const handleDownloadTheme = async () => {
try {
downloading.value = true;
if (props.upgradeTheme) {
await apiClient.theme.upgradeThemeFromUri({
name: props.upgradeTheme.metadata.name,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
} else {
await apiClient.theme.installThemeFromUri({
installFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
}
Toast.success(
t(
props.upgradeTheme
? "core.common.toast.upgrade_success"
: "core.common.toast.install_success"
)
);
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
handleVisibleChange(false);
routeRemoteDownloadUrl.value = null;
} catch (error) {
console.log("Failed to download theme", error);
} finally {
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 as string;
nextTick(() => {
submitForm("theme-remote-download-form");
});
}
}
);
</script>
<template>
<VModal
:visible="visible"
:width="600"
:title="modalTitle"
:centered="false"
@update:visible="handleVisibleChange"
>
<UppyUpload
v-if="uploadVisible"
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.zip'],
}"
:endpoint="endpoint"
auto-proceed
@uploaded="onUploaded"
/>
<VTabs v-model:active-id="activeTabId" type="outline" class="!rounded-none">
<VTabItem id="local" :label="$t('core.theme.upload_modal.tabs.local')">
<UppyUpload
v-if="uploadVisible"
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.zip'],
}"
:endpoint="endpoint"
auto-proceed
@uploaded="onUploaded"
/>
</VTabItem>
<VTabItem
id="remote"
:label="$t('core.theme.upload_modal.tabs.remote.title')"
>
<FormKit
id="theme-remote-download-form"
name="theme-remote-download-form"
type="form"
:preserve="true"
@submit="handleDownloadTheme"
>
<FormKit
v-model="remoteDownloadUrl"
:label="$t('core.theme.upload_modal.tabs.remote.fields.url')"
type="text"
></FormKit>
</FormKit>
<div class="pt-5">
<VButton
:loading="downloading"
type="secondary"
@click="$formkit.submit('theme-remote-download-form')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</VTabItem>
</VTabs>
</VModal>
</template>

View File

@ -24,6 +24,7 @@ import {
VSpace,
VTabbar,
VLoading,
Dialog,
} from "@halo-dev/components";
import ThemeListModal from "../components/ThemeListModal.vue";
import ThemePreviewModal from "../components/preview/ThemePreviewModal.vue";
@ -34,6 +35,7 @@ import { storeToRefs } from "pinia";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
@ -157,6 +159,27 @@ onMounted(() => {
watch([() => route.name, () => route.params], async () => {
handleTriggerTabChange();
});
// handle remote download url from route
const remoteDownloadUrl = useRouteQuery<string | null>("remote-download-url");
onMounted(() => {
if (remoteDownloadUrl.value) {
Dialog.warning({
title: t("core.theme.operations.remote_download.title"),
description: t("core.theme.operations.remote_download.description", {
url: remoteDownloadUrl.value,
}),
confirmText: t("core.common.buttons.download"),
cancelText: t("core.common.buttons.cancel"),
onConfirm() {
themesModal.value = true;
},
onCancel() {
remoteDownloadUrl.value = null;
},
});
}
});
</script>
<template>
<BasicLayout>