feat: add author pages for theme-side (#2923)

#### What type of PR is this?
/kind feature
/area core

#### What this PR does / why we need it:
为主题端新增作者页面
see #2837 for more detail
#### Which issue(s) this PR fixes:

Fixes #2837

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
主题端支持作者页面
```
pull/2942/head^2
guqing 2022-12-14 17:32:18 +08:00 committed by GitHub
parent 3033ceb1ec
commit 7a2f5d0a99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 476 additions and 22 deletions

View File

@ -71,6 +71,8 @@ public class User extends AbstractExtension {
private Instant lastLoginAt;
private String permalink;
private List<LoginHistory> loginHistories;
}

View File

@ -1,30 +1,113 @@
package run.halo.app.core.extension.reconciler;
import java.util.HashSet;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
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.extension.controller.Reconciler.Request;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
@Slf4j
@Component
@AllArgsConstructor
public class UserReconciler implements Reconciler<Request> {
private static final String FINALIZER_NAME = "user-protection";
private final ExtensionClient client;
public UserReconciler(ExtensionClient client) {
this.client = client;
}
private final ApplicationEventPublisher eventPublisher;
private final ExternalUrlSupplier externalUrlSupplier;
@Override
public Result reconcile(Request request) {
//TODO Add reconciliation logic here for User extension.
client.fetch(User.class, request.name()).ifPresent(user -> {
if (user.getMetadata().getDeletionTimestamp() != null) {
cleanUpResourcesAndRemoveFinalizer(request.name());
return;
}
addFinalizerIfNecessary(user);
updatePermalink(request.name());
});
return new Result(false, null);
}
private void updatePermalink(String name) {
client.fetch(User.class, name).ifPresent(user -> {
if (AnonymousUserConst.isAnonymousUser(name)) {
// anonymous user is not allowed to have permalink
return;
}
if (user.getStatus() == null) {
user.setStatus(new User.UserStatus());
}
User.UserStatus status = user.getStatus();
String oldPermalink = status.getPermalink();
status.setPermalink(getUserPermalink(user));
ExtensionLocator extensionLocator = getExtensionLocator(name);
eventPublisher.publishEvent(
new PermalinkIndexUpdateCommand(this, extensionLocator, status.getPermalink()));
if (!StringUtils.equals(oldPermalink, status.getPermalink())) {
client.update(user);
}
});
}
private static ExtensionLocator getExtensionLocator(String name) {
return new ExtensionLocator(GroupVersionKind.fromExtension(User.class), name,
name);
}
private String getUserPermalink(User user) {
return externalUrlSupplier.get()
.resolve(PathUtils.combinePath("authors", user.getMetadata().getName()))
.normalize().toString();
}
private void addFinalizerIfNecessary(User oldUser) {
Set<String> finalizers = oldUser.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(User.class, oldUser.getMetadata().getName())
.ifPresent(user -> {
Set<String> newFinalizers = user.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
user.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(user);
});
}
private void cleanUpResourcesAndRemoveFinalizer(String userName) {
client.fetch(User.class, userName).ifPresent(user -> {
eventPublisher.publishEvent(
new PermalinkIndexDeleteCommand(this, getExtensionLocator(userName)));
if (user.getMetadata().getFinalizers() != null) {
user.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(user);
});
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder

View File

@ -4,4 +4,8 @@ public interface AnonymousUserConst {
String PRINCIPAL = "anonymousUser";
String Role = "anonymous";
static boolean isAnonymousUser(String principal) {
return PRINCIPAL.equals(principal);
}
}

View File

@ -19,7 +19,9 @@ public enum DefaultTemplateEnum {
TAGS("tags"),
SINGLE_PAGE("page");
SINGLE_PAGE("page"),
AUTHOR("author");
private final String value;

View File

@ -4,14 +4,14 @@ import java.util.List;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.theme.finders.vo.Contributor;
import run.halo.app.theme.finders.vo.ContributorVo;
/**
* A finder for {@link User}.
*/
public interface ContributorFinder {
Mono<Contributor> getContributor(String name);
Mono<ContributorVo> getContributor(String name);
Flux<Contributor> getContributors(List<String> names);
Flux<ContributorVo> getContributors(List<String> names);
}

View File

@ -35,6 +35,9 @@ public interface PostFinder {
Mono<ListResult<ListedPostVo>> listByTag(@Nullable Integer page, @Nullable Integer size,
String tag);
Mono<ListResult<ListedPostVo>> listByOwner(@Nullable Integer page, @Nullable Integer size,
String owner);
Mono<ListResult<PostArchiveVo>> archives(Integer page, Integer size);
Mono<ListResult<PostArchiveVo>> archives(Integer page, Integer size, String year);

View File

@ -7,7 +7,7 @@ import run.halo.app.core.extension.User;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.vo.Contributor;
import run.halo.app.theme.finders.vo.ContributorVo;
/**
* A default implementation of {@link ContributorFinder}.
@ -25,13 +25,13 @@ public class ContributorFinderImpl implements ContributorFinder {
}
@Override
public Mono<Contributor> getContributor(String name) {
public Mono<ContributorVo> getContributor(String name) {
return client.fetch(User.class, name)
.map(Contributor::from);
.map(ContributorVo::from);
}
@Override
public Flux<Contributor> getContributors(List<String> names) {
public Flux<ContributorVo> getContributors(List<String> names) {
if (names == null) {
return Flux.empty();
}

View File

@ -220,6 +220,12 @@ public class PostFinderImpl implements PostFinder {
post -> contains(post.getSpec().getTags(), tag), defaultComparator());
}
@Override
public Mono<ListResult<ListedPostVo>> listByOwner(Integer page, Integer size, String owner) {
return listPost(page, size,
post -> post.getSpec().getOwner().equals(owner), defaultComparator());
}
@Override
public Mono<ListResult<PostArchiveVo>> archives(Integer page, Integer size) {
return archives(page, size, null, null);

View File

@ -14,7 +14,7 @@ import run.halo.app.core.extension.User;
@Value
@ToString
@Builder
public class Contributor {
public class ContributorVo {
String name;
String displayName;
@ -23,17 +23,22 @@ public class Contributor {
String bio;
String permalink;
/**
* Convert {@link User} to {@link Contributor}.
* Convert {@link User} to {@link ContributorVo}.
*
* @param user user extension
* @return contributor value object
*/
public static Contributor from(User user) {
public static ContributorVo from(User user) {
User.UserStatus status = user.getStatus();
String permalink = (status == null ? "" : status.getPermalink());
return builder().name(user.getMetadata().getName())
.displayName(user.getSpec().getDisplayName())
.avatar(user.getSpec().getAvatar())
.bio(user.getSpec().getBio())
.permalink(permalink)
.build();
}
}

View File

@ -31,9 +31,9 @@ public class ListedPostVo {
private List<TagVo> tags;
private List<Contributor> contributors;
private List<ContributorVo> contributors;
private Contributor owner;
private ContributorVo owner;
private StatsVo stats;

View File

@ -29,9 +29,9 @@ public class ListedSinglePageVo {
private StatsVo stats;
private List<Contributor> contributors;
private List<ContributorVo> contributors;
private Contributor owner;
private ContributorVo owner;
/**
* Convert {@link SinglePage} to {@link ListedSinglePageVo}.

View File

@ -0,0 +1,40 @@
package run.halo.app.theme.finders.vo;
import java.util.List;
import lombok.Builder;
import lombok.Value;
import org.apache.commons.lang3.ObjectUtils;
import run.halo.app.core.extension.User;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.infra.utils.JsonUtils;
@Value
@Builder
public class UserVo {
MetadataOperator metadata;
User.UserSpec spec;
User.UserStatus status;
/**
* Converts to {@link UserVo} from {@link User}.
*
* @param user user extension
* @return user value object.
*/
public static UserVo from(User user) {
User.UserStatus statusCopy =
JsonUtils.deepCopy(ObjectUtils.defaultIfNull(user.getStatus(), new User.UserStatus()));
statusCopy.setLoginHistories(List.of());
statusCopy.setLastLoginAt(null);
User.UserSpec userSpecCopy = JsonUtils.deepCopy(user.getSpec());
userSpecCopy.setPassword("[PROTECTED]");
return UserVo.builder()
.metadata(user.getMetadata())
.spec(userSpecCopy)
.status(statusCopy)
.build();
}
}

View File

@ -50,7 +50,7 @@ public class PermalinkPatternProvider {
public String getPattern(DefaultTemplateEnum defaultTemplateEnum) {
SystemSetting.ThemeRouteRules permalinkRules = getPermalinkRules();
return switch (defaultTemplateEnum) {
case INDEX, SINGLE_PAGE -> null;
case INDEX, SINGLE_PAGE, AUTHOR -> null;
case POST -> permalinkRules.getPost();
case ARCHIVES -> permalinkRules.getArchives();
case CATEGORY, CATEGORIES -> permalinkRules.getCategories();

View File

@ -0,0 +1,76 @@
package run.halo.app.theme.router.strategy;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.core.extension.User;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
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.finders.vo.UserVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* Author route strategy.
*
* @author guqing
* @since 2.0.1
*/
@Component
@AllArgsConstructor
public class AuthorRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final ReactiveExtensionClient client;
private final PostFinder postFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.AUTHOR.getValue(),
Map.of("name", name,
"author", getByName(name),
"posts", postList(request, name),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.AUTHOR.getValue()
)
);
}
private Mono<UrlContextListResult<ListedPostVo>> postList(ServerRequest request, String name) {
String path = request.path();
return environmentFetcher.fetchPost()
.map(p -> defaultIfNull(p.getPostPageSize(), ModelConst.DEFAULT_PAGE_SIZE))
.flatMap(pageSize -> postFinder.listByOwner(pageNum(request), pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
private Mono<UserVo> getByName(String name) {
return client.fetch(User.class, name)
.map(UserVo::from);
}
@Override
public boolean supports(GroupVersionKind gvk) {
return GroupVersionKind.fromExtension(User.class).equals(gvk);
}
}

View File

@ -0,0 +1,81 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
/**
* Tests for {@link UserReconciler}.
*
* @author guqing
* @since 2.0.1
*/
@ExtendWith(MockitoExtension.class)
class UserReconcilerTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private ExternalUrlSupplier externalUrlSupplier;
@Mock
private ExtensionClient client;
@InjectMocks
private UserReconciler userReconciler;
@Test
void permalinkForFakeUser() throws URISyntaxException {
when(externalUrlSupplier.get()).thenReturn(new URI("http://localhost:8090"));
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Optional.of(user("fake-user")));
userReconciler.reconcile(new Reconciler.Request("fake-user"));
verify(client, times(1)).update(any(User.class));
verify(eventPublisher, times(1)).publishEvent(any(PermalinkIndexUpdateCommand.class));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(client, times(1)).update(captor.capture());
assertThat(captor.getValue().getStatus().getPermalink())
.isEqualTo("http://localhost:8090/authors/fake-user");
}
@Test
void permalinkForAnonymousUser() {
when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL)))
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
verify(client, times(0)).update(any(User.class));
verify(eventPublisher, times(0)).publishEvent(any(PermalinkIndexUpdateCommand.class));
}
User user(String name) {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName(name);
user.getMetadata().setFinalizers(Set.of("user-protection"));
return user;
}
}

View File

@ -0,0 +1,84 @@
package run.halo.app.theme.finders.vo;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.List;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.User;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link UserVo}.
*
* @author guqing
* @since 2.0.1
*/
class UserVoTest {
@Test
void from() throws JSONException {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("fake-user");
user.setSpec(new User.UserSpec());
user.getSpec().setPassword("123456");
user.getSpec().setEmail("example@example.com");
user.getSpec().setAvatar("avatar");
user.getSpec().setDisplayName("fake-user-display-name");
user.getSpec().setBio("user bio");
user.getSpec().setDisabled(false);
user.getSpec().setPhone("123456789");
user.getSpec().setRegisteredAt(Instant.parse("2022-01-01T00:00:00.00Z"));
user.getSpec().setLoginHistoryLimit(5);
user.getSpec().setTwoFactorAuthEnabled(false);
user.setStatus(new User.UserStatus());
user.getStatus().setLastLoginAt(Instant.parse("2022-01-02T00:00:00.00Z"));
User.LoginHistory loginHistory = new User.LoginHistory();
loginHistory.setLoginAt(Instant.parse("2022-01-02T00:00:00.00Z"));
loginHistory.setReason("login reason");
loginHistory.setUserAgent("user agent");
user.getStatus().setLoginHistories(List.of(loginHistory));
UserVo userVo = UserVo.from(user);
JSONAssert.assertEquals("""
{
"metadata": {
"name": "fake-user"
},
"spec": {
"displayName": "fake-user-display-name",
"avatar": "avatar",
"email": "example@example.com",
"phone": "123456789",
"password": "[PROTECTED]",
"bio": "user bio",
"registeredAt": "2022-01-01T00:00:00Z",
"twoFactorAuthEnabled": false,
"disabled": false,
"loginHistoryLimit": 5
},
"status": {
"loginHistories": []
}
}
""",
JsonUtils.objectToJson(userVo),
true);
}
@Test
void fromWhenStatusIsNull() {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("fake-user");
user.setSpec(new User.UserSpec());
UserVo userVo = UserVo.from(user);
assertThat(userVo).isNotNull();
}
}

View File

@ -0,0 +1,68 @@
package run.halo.app.theme.router.strategy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* Tests for {@link AuthorRouteStrategy}.
*
* @author guqing
* @since 2.0.1
*/
class AuthorRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private AuthorRouteStrategy strategy;
@Test
void handlerTest() {
User user = new User();
Metadata metadata = new Metadata();
metadata.setName("fake-user");
user.setMetadata(metadata);
user.setSpec(new User.UserSpec());
when(client.fetch(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user));
permalinkHttpGetRouter.insert("/authors/fake-user",
strategy.getHandler(getThemeRouteRules(), "fake-user"));
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.AUTHOR.getValue()), any()))
.thenReturn(Mono.just(new EmptyView() {
@Override
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
ServerWebExchange exchange) {
assertThat(model.get("name")).isEqualTo("fake-user");
assertThat(model.get("_templateId"))
.isEqualTo(DefaultTemplateEnum.AUTHOR.getValue());
assertThat(model.get("author")).isNotNull();
assertThat(model.get("posts")).isNotNull();
return Mono.empty();
}
}));
WebTestClient webTestClient = getWebTestClient(getRouterFunction());
webTestClient.get()
.uri("/authors/fake-user")
.exchange()
.expectStatus()
.isOk();
}
}