Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add author pages for theme-side #2923

Merged
merged 10 commits into from
Dec 14, 2022
Next Next commit
feat: add author pages for theme-side
guqing committed Dec 12, 2022

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 6f5ac7d04a9969d42693908dcb1c292d0edb4ad9
2 changes: 2 additions & 0 deletions src/main/java/run/halo/app/core/extension/User.java
Original file line number Diff line number Diff line change
@@ -71,6 +71,8 @@ public static class UserStatus {

private Instant lastLoginAt;

private String permalink;

private List<LoginHistory> loginHistories;

}
Original file line number Diff line number Diff line change
@@ -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.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.JsonUtils;
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;
}

final User oldUser = JsonUtils.deepCopy(user);
guqing marked this conversation as resolved.
Show resolved Hide resolved
if (user.getStatus() == null) {
user.setStatus(new User.UserStatus());
}
User.UserStatus status = user.getStatus();
status.setPermalink(getUserPermalink(user));

ExtensionLocator extensionLocator = getExtensionLocator(name);
eventPublisher.publishEvent(
new PermalinkIndexUpdateCommand(this, extensionLocator, status.getPermalink()));

if (!user.equals(oldUser)) {
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
4 changes: 4 additions & 0 deletions src/main/java/run/halo/app/infra/AnonymousUserConst.java
Original file line number Diff line number Diff line change
@@ -4,4 +4,8 @@ public interface AnonymousUserConst {
String PRINCIPAL = "anonymousUser";

String Role = "anonymous";

static boolean isAnonymousUser(String principal) {
return PRINCIPAL.equals(principal);
}
}
4 changes: 3 additions & 1 deletion src/main/java/run/halo/app/theme/DefaultTemplateEnum.java
Original file line number Diff line number Diff line change
@@ -19,7 +19,9 @@ public enum DefaultTemplateEnum {

TAGS("tags"),

SINGLE_PAGE("page");
SINGLE_PAGE("page"),

AUTHOR("author");

private final String value;

40 changes: 40 additions & 0 deletions src/main/java/run/halo/app/theme/finders/vo/UserVo.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -50,7 +50,7 @@ private SystemSetting.ThemeRouteRules getPermalinkRules() {
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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package run.halo.app.theme.router.strategy;

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.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.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.vo.UserVo;

/**
* Author route strategy.
*
* @author guqing
* @since 2.0.1
*/
@Component
@AllArgsConstructor
public class AuthorRouteStrategy implements DetailsPageRouteHandlerStrategy {

private final ReactiveExtensionClient client;

@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),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.AUTHOR.getValue()
)
);
}

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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
84 changes: 84 additions & 0 deletions src/test/java/run/halo/app/theme/finders/vo/UserVoTest.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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();
return Mono.empty();
}
}));

WebTestClient webTestClient = getWebTestClient(getRouterFunction());
webTestClient.get()
.uri("/authors/fake-user")
.exchange()
.expectStatus()
.isOk();
}
}