feat: implement a feature for previewing posts and single pages (#3983)

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

#### What this PR does / why we need it:
新增文章和自定义页面预览功能

提供了以下两个路由用于预览,必须登录且是对应文章或自定义页面的 contributors 才能访问,如果不是 contributor 则先得到没有权限访问异常,如果有权限访问但预览文章不存在则得到 404
- 文章预览 `GET /preview/posts/{name}`
- 自定义页面预览 `GET /preview/singlepages/{name}`

两个路由都可以通过查询参数 snapshotName 来指定需要预览的内容快照,它是可选的,默认为当前正在编辑的内容

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

Fixes #2349

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

```release-note
新增文章和自定义页面预览功能
```
pull/3993/head^2
guqing 2023-05-25 22:54:18 +08:00 committed by GitHub
parent 533f0cfa66
commit da5fb1a252
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 675 additions and 126 deletions

View File

@ -18,7 +18,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.router.factories.ModelConst;
import run.halo.app.theme.router.ModelConst;
/**
* <p>The <code>head</code> html snippet injection processor for content template such as post

View File

@ -10,7 +10,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.factories.ModelConst;
import run.halo.app.theme.router.ModelConst;
/**
* <p>Global custom head snippet injection for theme global setting.</p>

View File

@ -0,0 +1,21 @@
package run.halo.app.theme.finders;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.theme.finders.vo.ListedSinglePageVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
/**
* A service that converts {@link SinglePage} to {@link SinglePageVo}.
*
* @author guqing
* @since 2.6.0
*/
public interface SinglePageConversionService {
Mono<SinglePageVo> convertToVo(SinglePage singlePage, String snapshotName);
Mono<SinglePageVo> convertToVo(SinglePage singlePage);
Mono<ListedSinglePageVo> convertToListedVo(SinglePage singlePage);
}

View File

@ -133,7 +133,7 @@ public class PostFinderImpl implements PostFinder {
int index = elements.indexOf(currentName);
String previousPostName = null;
if (index != 0) {
if (index > 0) {
previousPostName = elements.get(index - 1);
}

View File

@ -0,0 +1,113 @@
package run.halo.app.theme.finders.impl;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Mono;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedSinglePageVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
import run.halo.app.theme.finders.vo.StatsVo;
/**
* Default implementation of {@link SinglePageConversionService}.
*
* @author guqing
* @since 2.6.0
*/
@Component
@RequiredArgsConstructor
public class SinglePageConversionServiceImpl implements SinglePageConversionService {
private final SinglePageService singlePageService;
private final ContributorFinder contributorFinder;
private final CounterService counterService;
@Override
public Mono<SinglePageVo> convertToVo(SinglePage singlePage, String snapshotName) {
return convert(singlePage, snapshotName);
}
@Override
public Mono<SinglePageVo> convertToVo(SinglePage singlePage) {
return convert(singlePage, singlePage.getSpec().getReleaseSnapshot());
}
@Override
public Mono<ListedSinglePageVo> convertToListedVo(SinglePage singlePage) {
return Mono.fromSupplier(
() -> {
ListedSinglePageVo pageVo = ListedSinglePageVo.from(singlePage);
pageVo.setContributors(List.of());
return pageVo;
})
.flatMap(this::populateStats)
.flatMap(this::populateContributors);
}
Mono<SinglePageVo> convert(SinglePage singlePage, String snapshotName) {
Assert.notNull(singlePage, "Single page must not be null");
Assert.hasText(snapshotName, "Snapshot name must not be empty");
return Mono.just(singlePage)
.map(page -> {
SinglePageVo pageVo = SinglePageVo.from(page);
pageVo.setContributors(List.of());
pageVo.setContent(ContentVo.empty());
return pageVo;
})
.flatMap(this::populateStats)
.flatMap(this::populateContributors)
.flatMap(page -> populateContent(page, snapshotName))
.flatMap(page -> contributorFinder.getContributor(page.getSpec().getOwner())
.doOnNext(page::setOwner)
.thenReturn(page)
);
}
Mono<SinglePageVo> populateContent(SinglePageVo singlePageVo, String snapshotName) {
Assert.notNull(singlePageVo, "Single page vo must not be null");
Assert.hasText(snapshotName, "Snapshot name must not be empty");
return singlePageService.getContent(snapshotName, singlePageVo.getSpec().getBaseSnapshot())
.map(contentWrapper -> ContentVo.builder()
.content(contentWrapper.getContent())
.raw(contentWrapper.getRaw())
.build()
)
.doOnNext(singlePageVo::setContent)
.thenReturn(singlePageVo);
}
<T extends ListedSinglePageVo> Mono<T> populateStats(T pageVo) {
String name = pageVo.getMetadata().getName();
return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name))
.map(counter -> StatsVo.builder()
.visit(counter.getVisit())
.upvote(counter.getUpvote())
.comment(counter.getApprovedComment())
.build()
)
.doOnNext(pageVo::setStats)
.thenReturn(pageVo);
}
<T extends ListedSinglePageVo> Mono<T> populateContributors(T pageVo) {
List<String> names = pageVo.getStatus().getContributors();
if (CollectionUtils.isEmpty(names)) {
return Mono.just(pageVo);
}
return contributorFinder.getContributors(names)
.collectList()
.doOnNext(pageVo::setContributors)
.thenReturn(pageVo);
}
}

View File

@ -10,7 +10,6 @@ import java.util.function.Predicate;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.SinglePageService;
@ -18,15 +17,12 @@ import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedSinglePageVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
import run.halo.app.theme.finders.vo.StatsVo;
/**
* A default implementation of {@link SinglePage}.
@ -44,35 +40,15 @@ public class SinglePageFinderImpl implements SinglePageFinder {
private final ReactiveExtensionClient client;
private final SinglePageConversionService singlePagePublicQueryService;
private final SinglePageService singlePageService;
private final ContributorFinder contributorFinder;
private final CounterService counterService;
@Override
public Mono<SinglePageVo> getByName(String pageName) {
return client.get(SinglePage.class, pageName)
.filter(FIXED_PREDICATE)
.map(page -> {
SinglePageVo pageVo = SinglePageVo.from(page);
pageVo.setContributors(List.of());
pageVo.setContent(ContentVo.empty());
return pageVo;
})
.flatMap(singlePageVo -> fetchStats(singlePageVo)
.doOnNext(singlePageVo::setStats)
.thenReturn(singlePageVo)
)
.flatMap(this::populateContributors)
.flatMap(page -> content(pageName)
.doOnNext(page::setContent)
.thenReturn(page)
)
.flatMap(page -> contributorFinder.getContributor(page.getSpec().getOwner())
.doOnNext(page::setOwner)
.thenReturn(page)
);
.flatMap(singlePagePublicQueryService::convertToVo);
}
@Override
@ -98,13 +74,7 @@ public class SinglePageFinderImpl implements SinglePageFinder {
return client.list(SinglePage.class, predicateToUse,
comparatorToUse, pageNullSafe(page), sizeNullSafe(size))
.flatMap(list -> Flux.fromStream(list.get())
.map(singlePage -> {
ListedSinglePageVo pageVo = ListedSinglePageVo.from(singlePage);
pageVo.setContributors(List.of());
return pageVo;
})
.flatMap(lp -> fetchStats(lp).doOnNext(lp::setStats).thenReturn(lp))
.concatMap(this::populateContributors)
.concatMap(singlePagePublicQueryService::convertToListedVo)
.collectList()
.map(pageVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
pageVos)
@ -113,29 +83,6 @@ public class SinglePageFinderImpl implements SinglePageFinder {
.defaultIfEmpty(new ListResult<>(0, 0, 0, List.of()));
}
<T extends ListedSinglePageVo> Mono<StatsVo> fetchStats(T pageVo) {
String name = pageVo.getMetadata().getName();
return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name))
.map(counter -> StatsVo.builder()
.visit(counter.getVisit())
.upvote(counter.getUpvote())
.comment(counter.getApprovedComment())
.build()
)
.defaultIfEmpty(StatsVo.empty());
}
<T extends ListedSinglePageVo> Mono<T> populateContributors(T pageVo) {
List<String> names = pageVo.getStatus().getContributors();
if (CollectionUtils.isEmpty(names)) {
return Mono.just(pageVo);
}
return contributorFinder.getContributors(names)
.collectList()
.doOnNext(pageVo::setContributors)
.thenReturn(pageVo);
}
static Comparator<SinglePage> defaultComparator() {
Function<SinglePage, Boolean> pinned =
page -> Objects.requireNonNullElse(page.getSpec().getPinned(), false);

View File

@ -1,4 +1,4 @@
package run.halo.app.theme.router.factories;
package run.halo.app.theme.router;
/**
* Static variable keys for view model.

View File

@ -0,0 +1,53 @@
package run.halo.app.theme.router;
import java.util.HashMap;
import java.util.Map;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.Scheme;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
/**
* A util class for building model map.
*
* @author guqing
* @since 2.6.0
*/
public abstract class ModelMapUtils {
private static final Scheme POST_SCHEME = Scheme.buildFromType(Post.class);
private static final Scheme SINGLE_PAGE_SCHEME = Scheme.buildFromType(SinglePage.class);
/**
* Build post view model.
*
* @param postVo post vo
* @return model map
*/
public static Map<String, Object> postModel(PostVo postVo) {
Map<String, Object> model = new HashMap<>();
model.put("name", postVo.getMetadata().getName());
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue());
model.put("groupVersionKind", POST_SCHEME.groupVersionKind());
model.put("plural", POST_SCHEME.plural());
model.put("post", postVo);
return model;
}
/**
* Build single page view model.
*
* @param pageVo page vo
* @return model map
*/
public static Map<String, Object> singlePageModel(SinglePageVo pageVo) {
Map<String, Object> model = new HashMap<>();
model.put("name", pageVo.getMetadata().getName());
model.put("groupVersionKind", SINGLE_PAGE_SCHEME.groupVersionKind());
model.put("plural", SINGLE_PAGE_SCHEME.plural());
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.SINGLE_PAGE.getValue());
model.put("singlePage", pageVo);
return model;
}
}

View File

@ -0,0 +1,185 @@
package run.halo.app.theme.router;
import java.security.Principal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ContributorVo;
import run.halo.app.theme.finders.vo.PostVo;
/**
* <p>Preview router for previewing posts and single pages.</p>
*
* @author guqing
* @since 2.6.0
*/
@Component
@RequiredArgsConstructor
public class PreviewRouterFunction {
static final String SNAPSHOT_NAME_PARAM = "snapshotName";
private final ReactiveExtensionClient client;
private final PostPublicQueryService postPublicQueryService;
private final ViewNameResolver viewNameResolver;
private final PostService postService;
private final SinglePageConversionService singlePageConversionService;
@Bean
RouterFunction<ServerResponse> previewRouter() {
return RouterFunctions.route()
.GET("/preview/posts/{name}", this::previewPost)
.GET("/preview/singlepages/{name}", this::previewSinglePage)
.build();
}
private Mono<ServerResponse> previewPost(ServerRequest request) {
final var name = request.pathVariable("name");
return currentAuthenticatedUserName()
.flatMap(principal -> client.fetch(Post.class, name))
.flatMap(post -> {
String snapshotName = request.queryParam(SNAPSHOT_NAME_PARAM)
.orElse(post.getSpec().getHeadSnapshot());
return convertToPostVo(post, snapshotName);
})
.flatMap(post -> canPreview(post.getContributors())
.doOnNext(canPreview -> {
if (!canPreview) {
throw new NotFoundException("Page not found.");
}
})
.thenReturn(post)
)
// Check permissions before throwing this exception
.switchIfEmpty(Mono.error(() -> new NotFoundException("Post not found.")))
.flatMap(postVo -> {
String template = postVo.getSpec().getTemplate();
Map<String, Object> model = ModelMapUtils.postModel(postVo);
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.POST.getValue())
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
});
}
private Mono<PostVo> convertToPostVo(Post post, String snapshotName) {
return postPublicQueryService.convertToListedPostVo(post)
.map(PostVo::from)
.doOnNext(postVo -> {
// fake some attributes only for preview when they are not published
Post.PostSpec spec = postVo.getSpec();
if (spec.getPublishTime() == null) {
spec.setPublishTime(Instant.now());
}
if (spec.getPublish() == null) {
spec.setPublish(false);
}
Post.PostStatus status = postVo.getStatus();
if (status == null) {
status = new Post.PostStatus();
postVo.setStatus(status);
}
if (status.getLastModifyTime() == null) {
status.setLastModifyTime(Instant.now());
}
})
.flatMap(postVo ->
postService.getContent(snapshotName, postVo.getSpec().getBaseSnapshot())
.map(contentWrapper -> ContentVo.builder()
.raw(contentWrapper.getRaw())
.content(contentWrapper.getContent())
.build()
)
.doOnNext(postVo::setContent)
.thenReturn(postVo)
);
}
private Mono<ServerResponse> previewSinglePage(ServerRequest request) {
final var name = request.pathVariable("name");
return currentAuthenticatedUserName()
.flatMap(principal -> client.fetch(SinglePage.class, name))
.flatMap(singlePage -> {
String snapshotName = request.queryParam(SNAPSHOT_NAME_PARAM)
.orElse(singlePage.getSpec().getHeadSnapshot());
return singlePageConversionService.convertToVo(singlePage, snapshotName);
})
.doOnNext(pageVo -> {
// fake some attributes only for preview when they are not published
SinglePage.SinglePageSpec spec = pageVo.getSpec();
if (spec.getPublishTime() == null) {
spec.setPublishTime(Instant.now());
}
if (spec.getPublish() == null) {
spec.setPublish(false);
}
SinglePage.SinglePageStatus status = pageVo.getStatus();
if (status == null) {
status = new SinglePage.SinglePageStatus();
pageVo.setStatus(status);
}
if (status.getLastModifyTime() == null) {
status.setLastModifyTime(Instant.now());
}
})
.flatMap(singlePageVo -> canPreview(singlePageVo.getContributors())
.doOnNext(canPreview -> {
if (!canPreview) {
throw new NotFoundException("Page not found.");
}
})
.thenReturn(singlePageVo)
)
// Check permissions before throwing this exception
.switchIfEmpty(Mono.error(() -> new NotFoundException("Single page not found.")))
.flatMap(singlePageVo -> {
Map<String, Object> model = ModelMapUtils.singlePageModel(singlePageVo);
String template = singlePageVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.SINGLE_PAGE.getValue())
.flatMap(viewName -> ServerResponse.ok().render(viewName, model));
});
}
private Mono<Boolean> canPreview(List<ContributorVo> contributors) {
Assert.notNull(contributors, "The contributors must not be null");
Set<String> contributorNames = contributors.stream()
.map(ContributorVo::getName)
.collect(Collectors.toSet());
return currentAuthenticatedUserName()
.map(contributorNames::contains)
.defaultIfEmpty(false);
}
Mono<String> currentAuthenticatedUserName() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName)
.filter(name -> !AnonymousUserConst.isAnonymousUser(name));
}
}

View File

@ -2,7 +2,6 @@ package run.halo.app.theme.router;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ -25,16 +24,12 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.router.factories.ModelConst;
/**
* The {@link SinglePageRoute} for route request to specific template <code>page.html</code>.
@ -46,8 +41,6 @@ import run.halo.app.theme.router.factories.ModelConst;
@RequiredArgsConstructor
public class SinglePageRoute
implements RouterFunction<ServerResponse>, Reconciler<Reconciler.Request>, DisposableBean {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class);
private final Map<NameSlugPair, HandlerFunction<ServerResponse>> quickRouteMap =
new ConcurrentHashMap<>();
@ -123,12 +116,7 @@ public class SinglePageRoute
HandlerFunction<ServerResponse> handlerFunction(String name) {
return request -> singlePageFinder.getByName(name)
.flatMap(singlePageVo -> {
Map<String, Object> model = new HashMap<>();
model.put("name", singlePageVo.getMetadata().getName());
model.put("groupVersionKind", gvk);
model.put("plural", getPlural());
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.SINGLE_PAGE.getValue());
model.put("singlePage", singlePageVo);
Map<String, Object> model = ModelMapUtils.singlePageModel(singlePageVo);
String template = singlePageVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.SINGLE_PAGE.getValue())
@ -138,9 +126,4 @@ public class SinglePageRoute
Mono.error(new NotFoundException("Single page not found"))
);
}
private String getPlural() {
GVK gvk = Scheme.getGvkFromType(SinglePage.class);
return gvk.plural();
}
}

View File

@ -25,6 +25,7 @@ import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.router.ModelConst;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;

View File

@ -23,6 +23,7 @@ import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.UserVo;
import run.halo.app.theme.router.ModelConst;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;

View File

@ -12,6 +12,7 @@ import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.router.ModelConst;
/**
* The {@link CategoriesRouteFactory} for generate {@link RouterFunction} specific to the

View File

@ -25,6 +25,7 @@ import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.CategoryVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.router.ModelConst;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
import run.halo.app.theme.router.ViewNameResolver;

View File

@ -19,6 +19,7 @@ import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.router.ModelConst;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;

View File

@ -6,7 +6,6 @@ import static run.halo.app.theme.finders.PostPublicQueryService.FIXED_PREDICATE;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
@ -30,8 +29,6 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.exception.NotFoundException;
@ -39,6 +36,7 @@ import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.router.ModelMapUtils;
import run.halo.app.theme.router.ViewNameResolver;
/**
@ -101,14 +99,7 @@ public class PostRouteFactory implements RouteFactory {
Mono<PostVo> postVoMono = bestMatchPost(patternVariable);
return postVoMono
.flatMap(postVo -> {
Map<String, Object> model = new HashMap<>();
model.put("name", postVo.getMetadata().getName());
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue());
model.put("groupVersionKind", GroupVersionKind.fromExtension(Post.class));
GVK gvk = Post.class.getAnnotation(GVK.class);
model.put("plural", gvk.plural());
model.put("post", postVo);
Map<String, Object> model = ModelMapUtils.postModel(postVo);
String template = postVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.POST.getValue())

View File

@ -10,6 +10,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.router.ModelConst;
/**
* @author guqing

View File

@ -14,6 +14,7 @@ import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.router.ModelConst;
/**
* The {@link TagsRouteFactory} for generate {@link RouterFunction} specific to the template

View File

@ -38,7 +38,7 @@ import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.UserVo;
import run.halo.app.theme.router.factories.ModelConst;
import run.halo.app.theme.router.ModelConst;
/**
* Tests for {@link HaloProcessorDialect}.

View File

@ -1,8 +1,8 @@
package run.halo.app.theme.finders.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -14,13 +14,12 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.SinglePageVo;
/**
* Tests for {@link SinglePageFinderImpl}.
@ -35,13 +34,7 @@ class SinglePageFinderImplTest {
private ReactiveExtensionClient client;
@Mock
private SinglePageService singlePageService;
@Mock
private ContributorFinder contributorFinder;
@Mock
private CounterService counterService;
private SinglePageConversionService singlePageConversionService;
@InjectMocks
private SinglePageFinderImpl singlePageFinder;
@ -64,21 +57,14 @@ class SinglePageFinderImplTest {
when(client.get(eq(SinglePage.class), eq(fakePageName)))
.thenReturn(Mono.just(singlePage));
when(counterService.getByName(anyString())).thenReturn(Mono.empty());
when(contributorFinder.getContributor(anyString())).thenReturn(Mono.empty());
when(singlePageService.getReleaseContent(anyString())).thenReturn(Mono.empty());
when(singlePageConversionService.convertToVo(eq(singlePage)))
.thenReturn(Mono.just(mock(SinglePageVo.class)));
singlePageFinder.getByName(fakePageName)
.as(StepVerifier::create)
.consumeNextWith(page -> {
assertThat(page.getStats()).isNotNull();
assertThat(page.getContent()).isNotNull();
})
.consumeNextWith(page -> assertThat(page).isNotNull())
.verifyComplete();
verify(client).get(SinglePage.class, fakePageName);
verify(counterService).getByName(anyString());
verify(singlePageService).getReleaseContent(anyString());
verify(contributorFinder).getContributor(anyString());
}
}

View File

@ -0,0 +1,180 @@
package run.halo.app.theme.router;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
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.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.ContributorVo;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
/**
* Tests for {@link PreviewRouterFunction}.
*
* @author guqing
* @since 2.6.x
*/
@ExtendWith(SpringExtension.class)
class PreviewRouterFunctionTest {
@Mock
private ReactiveExtensionClient client;
@Mock
private PostPublicQueryService postPublicQueryService;
@Mock
private ViewNameResolver viewNameResolver;
@Mock
private ViewResolver viewResolver;
@Mock
private PostService postService;
@Mock
private SinglePageConversionService singlePageConversionService;
@InjectMocks
private PreviewRouterFunction previewRouterFunction;
private WebTestClient webTestClient;
@BeforeEach
public void setUp() {
webTestClient = WebTestClient.bindToRouterFunction(previewRouterFunction.previewRouter())
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
when(viewResolver.resolveViewName(any(), any()))
.thenReturn(Mono.just(new EmptyView() {
@Override
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
ServerWebExchange exchange) {
return super.render(model, contentType, exchange);
}
}));
}
@Test
@WithMockUser(username = "testuser")
public void previewPost() {
Post post = new Post();
post.setMetadata(new Metadata());
post.getMetadata().setName("post1");
post.setSpec(new Post.PostSpec());
post.getSpec().setOwner("testuser");
post.getSpec().setHeadSnapshot("snapshot1");
post.getSpec().setBaseSnapshot("snapshot2");
post.getSpec().setTemplate("postTemplate");
when(client.fetch(eq(Post.class), eq("post1"))).thenReturn(Mono.just(post));
PostVo postVo = PostVo.from(post);
postVo.setContributors(contributorVos());
when(postPublicQueryService.convertToListedPostVo(post)).thenReturn(Mono.just(postVo));
ContentWrapper contentWrapper = ContentWrapper.builder()
.raw("raw content")
.content("formatted content")
.build();
when(postService.getContent(eq("snapshot1"), eq("snapshot2")))
.thenReturn(Mono.just(contentWrapper));
when(viewNameResolver.resolveViewNameOrDefault(any(), eq("postTemplate"),
eq("post"))).thenReturn(Mono.just("postView"));
webTestClient.get().uri("/preview/posts/post1")
.exchange()
.expectStatus().isOk();
verify(viewResolver).resolveViewName(any(), any());
verify(postService).getContent(eq("snapshot1"), eq("snapshot2"));
verify(client).fetch(eq(Post.class), eq("post1"));
}
@Test
public void previewPostWhenUnAuthenticated() {
webTestClient.get().uri("/preview/posts/post1")
.exchange()
.expectStatus().isEqualTo(404);
}
@Test
@WithMockUser(username = "testuser")
public void previewSinglePage() {
SinglePage singlePage = new SinglePage();
singlePage.setMetadata(new Metadata());
singlePage.getMetadata().setName("page1");
singlePage.setSpec(new SinglePage.SinglePageSpec());
singlePage.getSpec().setOwner("testuser");
singlePage.getSpec().setHeadSnapshot("snapshot1");
singlePage.getSpec().setTemplate("pageTemplate");
when(client.fetch(SinglePage.class, "page1")).thenReturn(Mono.just(singlePage));
SinglePageVo singlePageVo = SinglePageVo.from(singlePage);
singlePageVo.setContributors(contributorVos());
when(singlePageConversionService.convertToVo(singlePage, "snapshot1"))
.thenReturn(Mono.just(singlePageVo));
when(viewNameResolver.resolveViewNameOrDefault(any(), eq("pageTemplate"),
eq("page"))).thenReturn(Mono.just("pageView"));
webTestClient.get().uri("/preview/singlepages/page1")
.exchange()
.expectStatus().isOk();
verify(viewResolver).resolveViewName(any(), any());
verify(client).fetch(eq(SinglePage.class), eq("page1"));
}
@Test
public void previewSinglePageWhenUnAuthenticated() {
webTestClient.get().uri("/preview/singlepages/page1")
.exchange()
.expectStatus().isEqualTo(404);
}
@Test
@WithMockUser(username = AnonymousUserConst.PRINCIPAL)
public void previewWithAnonymousUser() {
webTestClient.get().uri("/preview/singlepages/page1")
.exchange()
.expectStatus().isEqualTo(404);
}
List<ContributorVo> contributorVos() {
ContributorVo contributorA = ContributorVo.builder()
.name("fake-user")
.build();
ContributorVo contributorB = ContributorVo.builder()
.name("testuser")
.build();
return List.of(contributorA, contributorB);
}
}

View File

@ -27,7 +27,6 @@ import run.halo.app.extension.Metadata;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.SinglePageVo;
import run.halo.app.theme.router.factories.ModelConst;
/**
* Tests for {@link SinglePageRoute}.

View File

@ -26,6 +26,7 @@ import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.router.EmptyView;
import run.halo.app.theme.router.ModelConst;
import run.halo.app.theme.router.ViewNameResolver;
/**

View File

@ -10,6 +10,7 @@ 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.infra.SystemSetting;
import run.halo.app.theme.router.ModelConst;
/**
* Tests for {@link RouteFactory}.

View File

@ -9,6 +9,7 @@ import {
IconSave,
Toast,
Dialog,
IconEye,
} from "@halo-dev/components";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
import type { SinglePage, SinglePageRequest } from "@halo-dev/api-client";
@ -34,6 +35,7 @@ import {
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { useI18n } from "vue-i18n";
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
const router = useRouter();
const { t } = useI18n();
@ -112,9 +114,11 @@ provide<ComputedRef<string | undefined>>(
const routeQueryName = useRouteQuery<string>("name");
const handleSave = async () => {
const handleSave = async (options?: { mute?: boolean }) => {
try {
saving.value = true;
if (!options?.mute) {
saving.value = true;
}
//Set default title and slug
if (!formState.value.page.spec.title) {
@ -139,7 +143,9 @@ const handleSave = async () => {
routeQueryName.value = data.metadata.name;
}
Toast.success(t("core.common.toast.save_success"));
if (!options?.mute) {
Toast.success(t("core.common.toast.save_success"));
}
handleClearCache(routeQueryName.value as string);
await handleFetchContent();
@ -323,6 +329,17 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
routeQueryName,
toRef(formState.value.content, "raw")
);
// SinglePage preview
const previewModal = ref(false);
const previewPending = ref(false);
const handlePreview = async () => {
previewPending.value = true;
await handleSave({ mute: true });
previewModal.value = true;
previewPending.value = false;
};
</script>
<template>
@ -334,6 +351,14 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
@saved="onSettingSaved"
@published="onSettingPublished"
/>
<UrlPreviewModal
v-if="isUpdateMode"
v-model:visible="previewModal"
:title="formState.page.spec.title"
:url="`/preview/singlepages/${formState.page.metadata.name}`"
/>
<VPageHeader :title="$t('core.page.title')">
<template #icon>
<IconPages class="mr-2 self-center" />
@ -345,6 +370,17 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
:provider="currentEditorProvider"
@select="handleChangeEditorProvider"
/>
<VButton
size="sm"
type="default"
:loading="previewPending"
@click="handlePreview"
>
<template #icon>
<IconEye class="h-full w-full" />
</template>
{{ $t("core.common.buttons.preview") }}
</VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
<template #icon>
<IconSave class="h-full w-full" />

View File

@ -427,6 +427,13 @@ const { mutate: changeVisibleMutation } = useMutation({
Toast.error(t("core.common.toast.operation_failed"));
},
});
const getExternalUrl = (singlePage: SinglePage) => {
if (singlePage.metadata.labels?.[singlePageLabels.PUBLISHED] === "true") {
return singlePage.status?.permalink;
}
return `/preview/singlepages/${singlePage.metadata.name}`;
};
</script>
<template>
@ -737,10 +744,8 @@ const { mutate: changeVisibleMutation } = useMutation({
<VStatusDot state="success" animate />
</RouterLink>
<a
v-if="singlePage.page.status?.permalink"
target="_blank"
:href="singlePage.page.status?.permalink"
:title="singlePage.page.status?.permalink"
:href="getExternalUrl(singlePage.page)"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />

View File

@ -9,6 +9,7 @@ import {
VSpace,
Toast,
Dialog,
IconEye,
} from "@halo-dev/components";
import PostSettingModal from "./components/PostSettingModal.vue";
import type { Post, PostRequest } from "@halo-dev/api-client";
@ -34,6 +35,7 @@ import {
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { useI18n } from "vue-i18n";
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
const router = useRouter();
const { t } = useI18n();
@ -112,9 +114,11 @@ provide<ComputedRef<string | undefined>>(
computed(() => formState.value.post.status?.permalink)
);
const handleSave = async () => {
const handleSave = async (options?: { mute?: boolean }) => {
try {
saving.value = true;
if (!options?.mute) {
saving.value = true;
}
// Set default title and slug
if (!formState.value.post.spec.title) {
@ -140,7 +144,9 @@ const handleSave = async () => {
name.value = data.metadata.name;
}
Toast.success(t("core.common.toast.save_success"));
if (!options?.mute) {
Toast.success(t("core.common.toast.save_success"));
}
handleClearCache(name.value as string);
await handleFetchContent();
} catch (e) {
@ -336,6 +342,17 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
name,
toRef(formState.value.content, "raw")
);
// Post preview
const previewModal = ref(false);
const previewPending = ref(false);
const handlePreview = async () => {
previewPending.value = true;
await handleSave({ mute: true });
previewModal.value = true;
previewPending.value = false;
};
</script>
<template>
@ -347,6 +364,14 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
@saved="onSettingSaved"
@published="onSettingPublished"
/>
<UrlPreviewModal
v-if="isUpdateMode"
v-model:visible="previewModal"
:title="formState.post.spec.title"
:url="`/preview/posts/${formState.post.metadata.name}`"
/>
<VPageHeader :title="$t('core.post.title')">
<template #icon>
<IconBookRead class="mr-2 self-center" />
@ -358,6 +383,17 @@ const { handleSetContentCache, handleResetCache, handleClearCache } =
:provider="currentEditorProvider"
@select="handleChangeEditorProvider"
/>
<VButton
size="sm"
type="default"
:loading="previewPending"
@click="handlePreview"
>
<template #icon>
<IconEye class="h-full w-full" />
</template>
{{ $t("core.common.buttons.preview") }}
</VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
<template #icon>
<IconSave class="h-full w-full" />

View File

@ -440,6 +440,13 @@ const { mutate: changeVisibleMutation } = useMutation({
Toast.error(t("core.common.toast.operation_failed"));
},
});
const getExternalUrl = (post: Post) => {
if (post.metadata.labels?.[postLabels.PUBLISHED] === "true") {
return post.status?.permalink;
}
return `/preview/posts/${post.metadata.name}`;
};
</script>
<template>
<PostSettingModal
@ -802,10 +809,8 @@ const { mutate: changeVisibleMutation } = useMutation({
<VStatusDot state="success" animate />
</RouterLink>
<a
v-if="post.post.status?.permalink"
target="_blank"
:href="post.post.status?.permalink"
:title="post.post.status?.permalink"
:href="getExternalUrl(post.post)"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />