feat: create reverse proxy for logo when plugin created (#2652)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.0
#### What this PR does / why we need it:
插件 logo 支持配置外部 URL 或相对于 resources 的路径

Console 端展示插件 logo 时使用的字段需要使用 status.logo 而非 spec.logo
#### Which issue(s) this PR fixes:
Fixes #2651

#### Special notes for your reviewer:
how to test it?
1. 插件配置 logo 为外部 url 例如 https://guqing.xyz/avatar
2. 安装此插件后不会注册 ReverseProxy 规则
3. logo 配置为相对于 resources 的路径例如:/logo.png
4. 安装此插件后可以访问到 /plugins/{your-plugin-name}/assets/logo.png
5. 开发模式启动插件后,修改了 plugin.yaml 中的 spec.logo 则也会更新 ReverseProxy 的 rule

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?
```release-note
插件 Logo 支持配置外部 URL 或相对于 resources 的路径
```
pull/2663/head
guqing 2022-11-03 10:48:21 +08:00 committed by GitHub
parent 1078145b18
commit 73df5e4576
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 269 additions and 49 deletions

View File

@ -112,6 +112,8 @@ public class Plugin extends AbstractExtension {
private String entry;
private String stylesheet;
private String logo;
}
@JsonIgnore

View File

@ -4,22 +4,29 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginStartingError;
import run.halo.app.plugin.resources.BundleResourceUtils;
@ -51,6 +58,7 @@ public class PluginReconciler implements Reconciler<Request> {
}
addFinalizerIfNecessary(plugin);
reconcilePluginState(plugin.getMetadata().getName());
createInitialReverseProxyIfNotPresent(plugin);
});
return new Result(false, null);
}
@ -78,6 +86,15 @@ public class PluginReconciler implements Reconciler<Request> {
}
}
String logo = plugin.getSpec().getLogo();
if (PathUtils.isAbsoluteUri(logo)) {
pluginStatus.setLogo(logo);
} else {
String assetsPrefix =
PluginConst.assertsRoutePrefix(plugin.getMetadata().getName());
pluginStatus.setLogo(PathUtils.combinePath(assetsPrefix, logo));
}
if (!plugin.equals(oldPlugin)) {
client.update(plugin);
}
@ -213,4 +230,45 @@ public class PluginReconciler implements Reconciler<Request> {
}
}
}
void createInitialReverseProxyIfNotPresent(Plugin plugin) {
String pluginName = plugin.getMetadata().getName();
String reverseProxyName = initialReverseProxyName(pluginName);
ReverseProxy reverseProxy = new ReverseProxy();
reverseProxy.setMetadata(new Metadata());
reverseProxy.getMetadata().setName(reverseProxyName);
// put label to identify this reverse
reverseProxy.getMetadata().setLabels(new HashMap<>());
reverseProxy.getMetadata().getLabels().put(PluginConst.PLUGIN_NAME_LABEL_NAME, pluginName);
reverseProxy.setRules(new ArrayList<>());
String logo = plugin.getSpec().getLogo();
if (StringUtils.isNotBlank(logo) && !PathUtils.isAbsoluteUri(logo)) {
ReverseProxy.ReverseProxyRule logoRule = new ReverseProxy.ReverseProxyRule(logo,
new ReverseProxy.FileReverseProxyProvider(null, logo));
reverseProxy.getRules().add(logoRule);
}
client.fetch(ReverseProxy.class, reverseProxyName)
.ifPresentOrElse(persisted -> {
if (isDevelopmentMode(pluginName)) {
reverseProxy.getMetadata()
.setVersion(persisted.getMetadata().getVersion());
client.update(reverseProxy);
}
}, () -> client.create(reverseProxy));
}
static String initialReverseProxyName(String pluginName) {
return pluginName + "-system-generated-reverse-proxy";
}
private boolean isDevelopmentMode(String name) {
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name);
if (pluginWrapper == null) {
return false;
}
return RuntimeMode.DEVELOPMENT.equals(pluginWrapper.getRuntimeMode());
}
}

View File

@ -1,5 +1,7 @@
package run.halo.app.infra.utils;
import java.net.URI;
import java.net.URISyntaxException;
import lombok.experimental.UtilityClass;
import org.apache.commons.lang3.StringUtils;
@ -12,6 +14,38 @@ import org.apache.commons.lang3.StringUtils;
@UtilityClass
public class PathUtils {
/**
* Every HTTP URL conforms to the syntax of a generic URI. The URI generic syntax consists of
* components organized hierarchically in order of decreasing significance from left to
* right:
* <pre>
* URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]
* </pre>
* The authority component consists of subcomponents:
* <pre>
* authority = [userinfo "@"] host [":" port]
* </pre>
* Examples of popular schemes include http, https, ftp, mailto, file, data and irc. URI
* schemes should be registered with the
* <a href="https://en.wikipedia.org/wiki/Internet_Assigned_Numbers_Authority">Internet Assigned Numbers Authority (IANA)</a>, although
* non-registered schemes are used in practice.
*
* @param uriString url or path
* @return true if the linkBase is absolute, otherwise false
* @see <a href="https://en.wikipedia.org/wiki/URL">URL</a>
*/
public static boolean isAbsoluteUri(final String uriString) {
if (StringUtils.isBlank(uriString)) {
return false;
}
try {
URI uri = new URI(uriString);
return uri.isAbsolute();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
/**
* Combine paths based on the passed in path segments parameters.
*

View File

@ -76,7 +76,7 @@ public abstract class BundleResourceUtils {
}
@Nullable
private static DefaultResourceLoader getResourceLoader(HaloPluginManager pluginManager,
public static DefaultResourceLoader getResourceLoader(HaloPluginManager pluginManager,
String pluginName) {
Assert.notNull(pluginManager, "Plugin manager must not be null");
PluginWrapper plugin = pluginManager.getPlugin(pluginName);

View File

@ -4,10 +4,13 @@ import static org.springframework.http.MediaType.ALL;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.server.PathContainer;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
@ -22,8 +25,10 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.core.extension.ReverseProxy.FileReverseProxyProvider;
import run.halo.app.core.extension.ReverseProxy.ReverseProxyRule;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.plugin.PluginApplicationContext;
import run.halo.app.plugin.ExtensionContextRegistry;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginConst;
/**
@ -36,39 +41,40 @@ import run.halo.app.plugin.PluginConst;
*/
@Slf4j
@Component
@AllArgsConstructor
public class ReverseProxyRouterFunctionFactory {
private final HaloPluginManager haloPluginManager;
private final ApplicationContext applicationContext;
/**
* <p>Create {@link RouterFunction} according to the {@link ReverseProxy} custom resource
* configuration of the plugin.</p>
* <p>Note that: returns {@code Null} if the plugin does not have a {@link ReverseProxy} custom
* resource.</p>
*
* @param applicationContext plugin application context or system application context
* @param pluginName plugin name(nullable if system)
* @return A reverse proxy RouterFunction handle(nullable)
*/
@NonNull
public Mono<RouterFunction<ServerResponse>> create(ReverseProxy reverseProxy,
ApplicationContext applicationContext) {
return createReverseProxyRouterFunction(reverseProxy, applicationContext);
String pluginName) {
return createReverseProxyRouterFunction(reverseProxy, nullSafePluginName(pluginName));
}
private Mono<RouterFunction<ServerResponse>> createReverseProxyRouterFunction(
ReverseProxy reverseProxy,
ApplicationContext applicationContext) {
ReverseProxy reverseProxy, @NonNull String pluginName) {
Assert.notNull(reverseProxy, "The reverseProxy must not be null.");
Assert.notNull(applicationContext, "The applicationContext must not be null.");
final var pluginId = getPluginId(applicationContext);
var rules = getReverseProxyRules(reverseProxy);
return rules.map(rule -> {
String routePath = buildRoutePath(pluginId, rule);
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginId,
String routePath = buildRoutePath(pluginName, rule);
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginName,
routePath);
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
request -> {
Resource resource =
loadResourceByFileRule(pluginId, applicationContext, rule, request);
loadResourceByFileRule(pluginName, rule, request);
if (!resource.exists()) {
return ServerResponse.notFound().build();
}
@ -78,11 +84,8 @@ public class ReverseProxyRouterFunctionFactory {
}).reduce(RouterFunction::and);
}
private String getPluginId(ApplicationContext applicationContext) {
if (applicationContext instanceof PluginApplicationContext pluginApplicationContext) {
return pluginApplicationContext.getPluginId();
}
return PluginConst.SYSTEM_PLUGIN_NAME;
private String nullSafePluginName(String pluginName) {
return pluginName == null ? PluginConst.SYSTEM_PLUGIN_NAME : pluginName;
}
private Flux<ReverseProxyRule> getReverseProxyRules(ReverseProxy reverseProxy) {
@ -105,15 +108,14 @@ public class ReverseProxyRouterFunctionFactory {
* <p>Note that a returned Resource handle does not imply an existing resource; you need to
* invoke {@link Resource#exists()} to check for existence</p>
*
* @param pluginApplicationContext load file from plugin
* @param pluginName plugin to load file by name
* @param rule reverse proxy rule
* @param request client request
* @return a Resource handle for the specified resource location by the plugin(never null);
*/
@NonNull
private Resource loadResourceByFileRule(String pluginId,
ApplicationContext pluginApplicationContext,
ReverseProxyRule rule, ServerRequest request) {
private Resource loadResourceByFileRule(String pluginName, ReverseProxyRule rule,
ServerRequest request) {
Assert.notNull(rule.file(), "File rule must not be null.");
FileReverseProxyProvider file = rule.file();
String directory = file.directory();
@ -124,14 +126,30 @@ public class ReverseProxyRouterFunctionFactory {
if (StringUtils.isNotBlank(configuredFilename)) {
filename = configuredFilename;
} else {
String routePath = buildRoutePath(pluginId, rule);
String routePath = buildRoutePath(pluginName, rule);
PathContainer pathContainer = PathPatternParser.defaultInstance.parse(routePath)
.extractPathWithinPattern(PathContainer.parsePath(request.path()));
filename = pathContainer.value();
}
String filePath = PathUtils.combinePath(directory, filename);
return pluginApplicationContext.getResource(filePath);
return getResourceLoader(pluginName).getResource(filePath);
}
private ResourceLoader getResourceLoader(String pluginName) {
ExtensionContextRegistry registry = ExtensionContextRegistry.getInstance();
if (registry.containsContext(pluginName)) {
return registry.getByPluginId(pluginName);
}
if (PluginConst.SYSTEM_PLUGIN_NAME.equals(pluginName)) {
return applicationContext;
}
DefaultResourceLoader resourceLoader =
BundleResourceUtils.getResourceLoader(haloPluginManager, pluginName);
if (resourceLoader == null) {
throw new NotFoundException("Plugin [" + pluginName + "] not found.");
}
return resourceLoader;
}
}

View File

@ -12,8 +12,6 @@ import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.plugin.ExtensionContextRegistry;
import run.halo.app.plugin.PluginApplicationContext;
/**
* A registry for {@link RouterFunction} of plugin.
@ -48,11 +46,7 @@ public class ReverseProxyRouterFunctionRegistry {
long stamp = lock.writeLock();
try {
pluginIdReverseProxyMap.put(pluginId, proxyName);
// Obtain plugin application context
PluginApplicationContext pluginApplicationContext =
ExtensionContextRegistry.getInstance().getByPluginId(pluginId);
return reverseProxyRouterFunctionFactory.create(reverseProxy, pluginApplicationContext)
return reverseProxyRouterFunctionFactory.create(reverseProxy, pluginId)
.map(routerFunction -> {
proxyNameRouterFunctionRegistry.put(proxyName, routerFunction);
return routerFunction;

View File

@ -4,13 +4,18 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.core.extension.reconciler.PluginReconciler.initialReverseProxyName;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.json.JSONException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@ -21,8 +26,12 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.HaloPluginManager;
@ -52,8 +61,8 @@ class PluginReconcilerTest {
void setUp() {
pluginReconciler = new PluginReconciler(extensionClient, haloPluginManager);
when(haloPluginManager.getPlugin(any())).thenReturn(pluginWrapper);
when(haloPluginManager.getUnresolvedPlugins()).thenReturn(List.of());
lenient().when(haloPluginManager.getPlugin(any())).thenReturn(pluginWrapper);
lenient().when(haloPluginManager.getUnresolvedPlugins()).thenReturn(List.of());
}
@Test
@ -66,7 +75,7 @@ class PluginReconcilerTest {
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
verify(extensionClient, times(2)).update(any());
verify(extensionClient, times(3)).update(isA(Plugin.class));
Plugin updateArgs = pluginCaptor.getValue();
assertThat(updateArgs).isNotNull();
@ -118,7 +127,7 @@ class PluginReconcilerTest {
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
verify(extensionClient, times(2)).update(any());
verify(extensionClient, times(3)).update(any(Plugin.class));
Plugin updateArgs = pluginCaptor.getValue();
assertThat(updateArgs).isNotNull();
@ -153,7 +162,7 @@ class PluginReconcilerTest {
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
verify(extensionClient, times(3)).update(any());
verify(extensionClient, times(3)).update(any(Plugin.class));
Plugin updateArgs = pluginCaptor.getValue();
assertThat(updateArgs).isNotNull();
@ -192,6 +201,80 @@ class PluginReconcilerTest {
.hasMessage("error message");
}
@Test
void createInitialReverseProxyWhenNotExistAndLogoIsPath() throws JSONException {
Plugin plugin = need2ReconcileForStopState();
String reverseProxyName = initialReverseProxyName(plugin.getMetadata().getName());
when(extensionClient.fetch(eq(ReverseProxy.class), eq(reverseProxyName)))
.thenReturn(Optional.empty());
plugin.getSpec().setLogo("/logo.png");
pluginReconciler.createInitialReverseProxyIfNotPresent(plugin);
ArgumentCaptor<ReverseProxy> captor = ArgumentCaptor.forClass(ReverseProxy.class);
verify(extensionClient, times(1)).create(captor.capture());
ReverseProxy value = captor.getValue();
JSONAssert.assertEquals("""
{
"rules": [
{
"path": "/logo.png",
"file": {
"filename": "/logo.png"
}
}
],
"apiVersion": "plugin.halo.run/v1alpha1",
"kind": "ReverseProxy",
"metadata": {
"name": "apples-system-generated-reverse-proxy",
"labels": {
"plugin.halo.run/plugin-name": "apples"
}
}
}
""",
JsonUtils.objectToJson(value),
true);
}
@Test
void createInitialReverseProxyWhenNotExistAndLogoIsAbsolute() {
Plugin plugin = need2ReconcileForStopState();
String reverseProxyName = initialReverseProxyName(plugin.getMetadata().getName());
when(extensionClient.fetch(eq(ReverseProxy.class), eq(reverseProxyName)))
.thenReturn(Optional.empty());
plugin.getSpec().setLogo("http://example.com/logo");
pluginReconciler.createInitialReverseProxyIfNotPresent(plugin);
ArgumentCaptor<ReverseProxy> captor = ArgumentCaptor.forClass(ReverseProxy.class);
verify(extensionClient, times(1)).create(captor.capture());
ReverseProxy value = captor.getValue();
assertThat(value.getRules()).isEmpty();
}
@Test
void createInitialReverseProxyWhenExist() {
Plugin plugin = need2ReconcileForStopState();
plugin.getSpec().setLogo("/logo.png");
String reverseProxyName = initialReverseProxyName(plugin.getMetadata().getName());
ReverseProxy reverseProxy = new ReverseProxy();
reverseProxy.setMetadata(new Metadata());
reverseProxy.getMetadata().setName(reverseProxyName);
reverseProxy.setRules(new ArrayList<>());
when(extensionClient.fetch(eq(ReverseProxy.class), eq(reverseProxyName)))
.thenReturn(Optional.of(reverseProxy));
when(pluginWrapper.getRuntimeMode()).thenReturn(RuntimeMode.DEPLOYMENT);
pluginReconciler.createInitialReverseProxyIfNotPresent(plugin);
verify(extensionClient, times(0)).update(any());
when(pluginWrapper.getRuntimeMode()).thenReturn(RuntimeMode.DEVELOPMENT);
pluginReconciler.createInitialReverseProxyIfNotPresent(plugin);
verify(extensionClient, times(1)).update(any());
}
private ArgumentCaptor<Plugin> doReconcileNeedRequeue() {
ArgumentCaptor<Plugin> pluginCaptor = ArgumentCaptor.forClass(Plugin.class);
doNothing().when(extensionClient).update(pluginCaptor.capture());

View File

@ -59,4 +59,40 @@ class PathUtilsTest {
assertThat(PathUtils.simplifyPathPattern("/archives/{year:\\d{4}}/page/{page:\\d+}"))
.isEqualTo("/archives/{year}/page/{page}");
}
@Test
void isAbsoluteUri() {
String[] absoluteUris = new String[] {
"ftp://ftp.is.co.za/rfc/rfc1808.txt",
"http://www.ietf.org/rfc/rfc2396.txt",
"ldap://[2001:db8::7]/c=GB?objectClass?one",
"mailto:John.Doe@example.com",
"news:comp.infosystems.www.servers.unix",
"tel:+1-816-555-1212",
"telnet://192.0.2.16:80/",
"urn:oasis:names:specification:docbook:dtd:xml:4.1.2",
"data:text/vnd-example+xyz;foo=bar;base64,R0lGODdh",
"irc://irc.example.com:6667/#some-channel",
"ircs://irc.example.com:6667/#some-channel",
"irc6://irc.example.com:6667/#some-channel"
};
for (String uri : absoluteUris) {
assertThat(PathUtils.isAbsoluteUri(uri)).isTrue();
}
String[] paths = new String[] {
"//example.com/path/resource.txt",
"/path/resource.txt",
"path/resource.txt",
"../resource.txt",
"./resource.txt",
"resource.txt",
"#fragment",
"",
null
};
for (String path : paths) {
assertThat(PathUtils.isAbsoluteUri(path)).isFalse();
}
}
}

View File

@ -1,18 +1,17 @@
package run.halo.app.plugin.resources;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.extension.Metadata;
import run.halo.app.plugin.PluginApplicationContext;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginConst;
/**
@ -25,22 +24,18 @@ import run.halo.app.plugin.PluginConst;
class ReverseProxyRouterFunctionFactoryTest {
@Mock
private PluginApplicationContext pluginApplicationContext;
private HaloPluginManager haloPluginManager;
@Mock
private ApplicationContext applicationContext;
@InjectMocks
private ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory;
@BeforeEach
void setUp() {
reverseProxyRouterFunctionFactory =
new ReverseProxyRouterFunctionFactory();
when(pluginApplicationContext.getPluginId()).thenReturn("fakeA");
}
@Test
void create() {
var routerFunction =
reverseProxyRouterFunctionFactory.create(mockReverseProxy(), pluginApplicationContext);
reverseProxyRouterFunctionFactory.create(mockReverseProxy(), "fakeA");
StepVerifier.create(routerFunction)
.expectNextCount(1)
.verifyComplete();