Add property halo.use-absolute-permalink to control permalink generation (#3772)

#### What type of PR is this?

/kind improvement
/kind api-change
/area core
/milestone 2.5.x

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

Add property `halo.use-absolute-permalink`(default is `false`) to control permalink generation. Leave `halo.external-url` as `null` by default.

Meanwhile, I enhanced `ExternalUrlSupplier#getURL` to get URL from not only properties but only http request.

#### How to use it?

```yaml
halo:
  use-absolute-permalink: false
```

Or:

```yaml
halo:
  external-url: https://halo.run/
  use-absolute-permalink: false
```

Or:

```yaml
halo:
  external-url: https://halo.run/
  use-absolute-permalink: true
```

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/3762

#### Special notes for your reviewer:

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

```release-note
新增是否生成相对地址的配置
```
pull/3747/head
John Niang 2023-04-19 15:54:24 +08:00 committed by GitHub
parent 762747624f
commit a94c0c7f85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 203 additions and 22 deletions

View File

@ -1,7 +1,9 @@
package run.halo.app.infra;
import java.net.URI;
import java.net.URL;
import java.util.function.Supplier;
import org.springframework.http.HttpRequest;
/**
* Represents a supplier of external url configuration.
@ -10,4 +12,22 @@ import java.util.function.Supplier;
*/
public interface ExternalUrlSupplier extends Supplier<URI> {
/**
* Gets URI according to external URL and use-absolute-permalink properties.
*
* @return URI "/" returned if use-absolute-permalink is false. Or external URL will be
* returned.(never null)
*/
@Override
URI get();
/**
* Gets URL according to external URL and server request URL.
*
* @param request represents an HTTP request message, consisting of a method and a URI.
* @return External URL will be return if it is provided, or request URI will be returned.
* (never null)
*/
URL getURL(HttpRequest request);
}

View File

@ -2,7 +2,7 @@ package run.halo.app.actuator;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
import java.net.URI;
import java.net.URL;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
@ -14,12 +14,12 @@ import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ConfigMap;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.SystemSetting.Basic;
import run.halo.app.infra.SystemSetting.Comment;
import run.halo.app.infra.SystemSetting.User;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.security.AuthProviderService;
@WebEndpoint(id = "globalinfo")
@ -29,14 +29,15 @@ public class GlobalInfoEndpoint {
private final ObjectProvider<SystemConfigurableEnvironmentFetcher> systemConfigFetcher;
private final ExternalUrlSupplier externalUrl;
private final HaloProperties haloProperties;
private final AuthProviderService authProviderService;
@ReadOperation
public GlobalInfo globalInfo() {
final var info = new GlobalInfo();
info.setExternalUrl(externalUrl.get());
info.setExternalUrl(haloProperties.getExternalUrl());
info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink());
info.setLocale(Locale.getDefault());
info.setTimeZone(TimeZone.getDefault());
handleSocialAuthProvider(info);
@ -53,7 +54,9 @@ public class GlobalInfoEndpoint {
@Data
public static class GlobalInfo {
private URI externalUrl;
private URL externalUrl;
private boolean useAbsolutePermalink;
private TimeZone timeZone;

View File

@ -1,7 +1,13 @@
package run.halo.app.infra;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.http.HttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import run.halo.app.infra.properties.HaloProperties;
/**
@ -14,12 +20,45 @@ public class HaloPropertiesExternalUrlSupplier implements ExternalUrlSupplier {
private final HaloProperties haloProperties;
public HaloPropertiesExternalUrlSupplier(HaloProperties haloProperties) {
private final WebFluxProperties webFluxProperties;
public HaloPropertiesExternalUrlSupplier(HaloProperties haloProperties,
WebFluxProperties webFluxProperties) {
this.haloProperties = haloProperties;
this.webFluxProperties = webFluxProperties;
}
@Override
public URI get() {
return haloProperties.getExternalUrl();
if (!haloProperties.isUseAbsolutePermalink()) {
return URI.create(getBasePath());
}
try {
return haloProperties.getExternalUrl().toURI();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
@Override
public URL getURL(HttpRequest request) {
var externalUrl = haloProperties.getExternalUrl();
if (externalUrl == null) {
try {
externalUrl = request.getURI().resolve(getBasePath()).toURL();
} catch (MalformedURLException e) {
throw new RuntimeException("Cannot parse request URI to URL.", e);
}
}
return externalUrl;
}
private String getBasePath() {
var basePath = webFluxProperties.getBasePath();
if (!StringUtils.hasText(basePath)) {
basePath = "/";
}
return basePath;
}
}

View File

@ -2,12 +2,14 @@ package run.halo.app.infra.properties;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.net.URL;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import org.springframework.validation.annotation.Validated;
/**
@ -17,13 +19,20 @@ import org.springframework.validation.annotation.Validated;
@Data
@ConfigurationProperties(prefix = "halo")
@Validated
public class HaloProperties {
public class HaloProperties implements Validator {
@NotNull
private Path workDir;
@NotNull
private URI externalUrl;
/**
* External URL must be a URL and it can be null.
*/
private URL externalUrl;
/**
* Indicates if we use absolute permalink to post, page, category, tag and so on.
*/
private boolean useAbsolutePermalink;
private Set<String> initialExtensionLocations = new HashSet<>();
@ -48,4 +57,18 @@ public class HaloProperties {
@Valid
private final AttachmentProperties attachment = new AttachmentProperties();
@Override
public boolean supports(Class<?> clazz) {
return HaloProperties.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
var props = (HaloProperties) target;
if (props.isUseAbsolutePermalink() && props.getExternalUrl() == null) {
errors.rejectValue("externalUrl", "external-url.required.when-using-absolute-permalink",
"External URL is required when property `use-absolute-permalink` is set to true.");
}
}
}

View File

@ -8,7 +8,7 @@ import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.utils.PathUtils;
/**
@ -21,10 +21,10 @@ import run.halo.app.infra.utils.PathUtils;
@Component
public class HaloTrackerProcessor implements TemplateHeadProcessor {
private final HaloProperties haloProperties;
private final ExternalUrlSupplier externalUrlGetter;
public HaloTrackerProcessor(HaloProperties haloProperties) {
this.haloProperties = haloProperties;
public HaloTrackerProcessor(ExternalUrlSupplier externalUrlGetter) {
this.externalUrlGetter = externalUrlGetter;
}
@Override
@ -42,7 +42,7 @@ public class HaloTrackerProcessor implements TemplateHeadProcessor {
private String getTrackerScript(ITemplateContext context) {
String resourceName = (String) context.getVariable("name");
String externalUrl = haloProperties.getExternalUrl().getPath();
String externalUrl = externalUrlGetter.get().getPath();
Object groupVersionKind = context.getVariable("groupVersionKind");
Object plural = context.getVariable("plural");
if (groupVersionKind == null || plural == null) {

View File

@ -29,7 +29,6 @@ spring:
max-age: 365d
halo:
external-url: "/"
work-dir: ${user.home}/.halo2
plugin:
plugins-root: ${halo.work-dir}/plugins

View File

@ -1,14 +1,20 @@
package run.halo.app.infra;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.http.HttpRequest;
import run.halo.app.infra.properties.HaloProperties;
@ExtendWith(MockitoExtension.class)
@ -17,13 +23,86 @@ class HaloPropertiesExternalUrlSupplierTest {
@Mock
HaloProperties haloProperties;
@Mock
WebFluxProperties webFluxProperties;
@InjectMocks
HaloPropertiesExternalUrlSupplier externalUrl;
@Test
void get() {
URI fakeUri = URI.create("fake-url");
Mockito.when(haloProperties.getExternalUrl()).thenReturn(fakeUri);
void getURIWhenUsingAbsolutePermalink() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
when(haloProperties.isUseAbsolutePermalink()).thenReturn(true);
assertEquals(fakeUri, externalUrl.get());
}
@Test
void getURIWhenBasePathSetAndNotUsingAbsolutePermalink() throws MalformedURLException {
when(webFluxProperties.getBasePath()).thenReturn("/blog");
when(haloProperties.isUseAbsolutePermalink()).thenReturn(false);
assertEquals(URI.create("/blog"), externalUrl.get());
}
@Test
void getURIWhenBasePathSetAndUsingAbsolutePermalink() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog");
when(haloProperties.isUseAbsolutePermalink()).thenReturn(true);
assertEquals(URI.create("https://halo.run/fake"), externalUrl.get());
}
@Test
void getURIWhenUsingRelativePermalink() throws MalformedURLException {
when(haloProperties.isUseAbsolutePermalink()).thenReturn(false);
assertEquals(URI.create("/"), externalUrl.get());
}
@Test
void getURLWhenExternalURLProvided() throws MalformedURLException {
var fakeUri = URI.create("https://halo.run/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
var mockRequest = mock(HttpRequest.class);
var url = externalUrl.getURL(mockRequest);
assertEquals(fakeUri.toURL(), url);
}
@Test
void getURLWhenExternalURLAbsent() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(null);
var mockRequest = mock(HttpRequest.class);
when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/"), url);
}
@Test
void getURLWhenBasePathSetAndExternalURLProvided() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(fakeUri.toURL());
lenient().when(webFluxProperties.getBasePath()).thenReturn("/blog");
var mockRequest = mock(HttpRequest.class);
lenient().when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/fake"), url);
}
@Test
void getURLWhenBasePathSetAndExternalURLAbsent() throws MalformedURLException {
var fakeUri = URI.create("https://localhost/fake");
when(haloProperties.getExternalUrl()).thenReturn(null);
when(webFluxProperties.getBasePath()).thenReturn("/blog");
var mockRequest = mock(HttpRequest.class);
when(mockRequest.getURI()).thenReturn(fakeUri);
var url = externalUrl.getURL(mockRequest);
assertEquals(new URL("https://localhost/blog"), url);
}
}

View File

@ -930,6 +930,9 @@ core:
database: Database
os: Operating system
log: System log
fields_values:
external_url:
not_setup: Not setup
copy_results:
external_url: "External url: {external_url}"
start_time: "Start time: {start_time}"

View File

@ -930,6 +930,9 @@ core:
database: 数据库
os: 操作系统
log: 运行日志
fields_values:
external_url:
not_setup: 未设置
copy_results:
external_url: 外部访问地址:{external_url}
start_time: 启动时间:{start_time}

View File

@ -930,6 +930,9 @@ core:
database: 資料庫
os: 操作系統
log: 運行日誌
fields_values:
external_url:
not_setup: 未設置
copy_results:
external_url: 外部訪問地址:{external_url}
start_time: 啟動時間:{start_time}

View File

@ -52,9 +52,14 @@ const handleFetchActuatorStartup = async () => {
};
const isExternalUrlValid = computed(() => {
if (!globalInfo.value?.useAbsolutePermalink) {
return true;
}
if (!globalInfo.value?.externalUrl) {
return true;
}
const url = new URL(globalInfo.value.externalUrl);
const { host: currentHost, protocol: currentProtocol } = window.location;
return url.host === currentHost && url.protocol === currentProtocol;
@ -162,9 +167,12 @@ const handleDownloadLogfile = () => {
{{ $t("core.actuator.fields.external_url") }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<span>
<span v-if="globalInfo?.externalUrl">
{{ globalInfo?.externalUrl }}
</span>
<span v-else>
{{ $t("core.actuator.fields_values.external_url.not_setup") }}
</span>
<VAlert
v-if="!isExternalUrlValid"
class="mt-3"

View File

@ -6,6 +6,7 @@ export interface GlobalInfo {
allowAnonymousComments: boolean;
allowRegistration: boolean;
socialAuthProviders: SocialAuthProvider[];
useAbsolutePermalink: boolean;
}
export interface Info {