Skip to content

feat: support route pattern /categories/{categorySlug}/{postSlug} for post access #7331

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

Merged
merged 1 commit into from
Apr 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package run.halo.app.content;

import java.util.List;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;

Expand Down Expand Up @@ -52,4 +54,6 @@ public interface PostService {
Mono<ContentWrapper> deleteContent(String postName, String snapshotName);

Mono<Post> recycleBy(String postName, String username);

Flux<Category> listCategories(List<String> categories);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.ToIntFunction;
import java.util.function.UnaryOperator;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
Expand Down Expand Up @@ -161,13 +163,17 @@ private Flux<Tag> listTags(List<String> tagNames) {
return client.listAll(Tag.class, listOptions, Sort.by("metadata.creationTimestamp"));
}

private Flux<Category> listCategories(List<String> categoryNames) {
@Override
public Flux<Category> listCategories(List<String> categoryNames) {
if (categoryNames == null) {
return Flux.empty();
}
ToIntFunction<Category> comparator =
category -> categoryNames.indexOf(category.getMetadata().getName());
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", categoryNames)));
return client.listAll(Category.class, listOptions, Sort.by("metadata.creationTimestamp"));
return client.listAll(Category.class, listOptions, Sort.unsorted())
.sort(Comparator.comparingInt(comparator));
}

private Flux<Contributor> listContributors(List<String> usernames) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.util.Properties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.MetadataUtil;
Expand All @@ -27,12 +28,14 @@
@Component
@RequiredArgsConstructor
public class PostPermalinkPolicy implements PermalinkPolicy<Post> {
public static final String DEFAULT_CATEGORY = "default";
public static final String DEFAULT_PERMALINK_PATTERN =
SystemSetting.ThemeRouteRules.empty().getPost();
private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00");

private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final ExternalUrlSupplier externalUrlSupplier;
private final PostService postService;

@Override
public String permalink(Post post) {
Expand Down Expand Up @@ -62,6 +65,13 @@ private String createPermalink(Post post, String pattern) {
properties.put("month", NUMBER_FORMAT.format(zonedDateTime.getMonthValue()));
properties.put("day", NUMBER_FORMAT.format(zonedDateTime.getDayOfMonth()));

var categorySlug = postService.listCategories(post.getSpec().getCategories())
.next()
.blockOptional()
.map(category -> category.getSpec().getSlug())
.orElse(DEFAULT_CATEGORY);
properties.put("categorySlug", categorySlug);

String simplifiedPattern = PathUtils.simplifyPathPattern(pattern);
String permalink =
PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(simplifiedPattern, properties);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@
import java.util.Set;
import lombok.AllArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import run.halo.app.content.CategoryService;
import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.event.post.CategoryHiddenStateChangeEvent;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
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.index.query.QueryFactory;

/**
* Reconciler for {@link Category}.
Expand All @@ -43,6 +47,7 @@ public Result reconcile(Request request) {
if (ExtensionUtil.isDeleted(category)) {
if (removeFinalizers(category.getMetadata(), Set.of(FINALIZER_NAME))) {
refreshHiddenState(category, false);
updateCategoryForPost(category.getMetadata().getName());
client.update(category);
}
return;
Expand Down Expand Up @@ -118,4 +123,18 @@ void populatePermalink(Category category) {
category.getStatusOrDefault()
.setPermalink(categoryPermalinkPolicy.permalink(category));
}

private void updateCategoryForPost(String categoryName) {
var posts = client.listAll(Post.class, ListOptions.builder()
.fieldQuery(QueryFactory.equal("spec.categories", categoryName))
.build(), Sort.by("metadata.creationTimestamp", "metadata.name")
);
for (Post post : posts) {
var categoryNames = post.getSpec().getCategories();
if (!CollectionUtils.isEmpty(categoryNames)) {
categoryNames.remove(categoryName);
}
client.update(post);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ public class ThemeCompositeRouterFunction implements RouterFunction<ServerRespon
@NonNull
public Mono<HandlerFunction<ServerResponse>> route(@NonNull ServerRequest request) {
return Flux.fromIterable(cachedRouters)
.concatMap(routerFunction -> routerFunction.route(request))
.concatMap(routerFunction -> routerFunction.route(request)
.filterWhen(handle -> handle.handle(request).hasElement())
)
.next();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static run.halo.app.content.permalinks.PostPermalinkPolicy.DEFAULT_CATEGORY;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
Expand Down Expand Up @@ -33,11 +34,11 @@
import org.springframework.web.server.i18n.LocaleContextResolver;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.ViewNameResolver;
Expand Down Expand Up @@ -69,6 +70,7 @@ public class PostRouteFactory implements RouteFactory {
private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator;

private final LocaleContextResolver localeContextResolver;
private final PostService postService;

@Override
public RouterFunction<ServerResponse> create(String pattern) {
Expand Down Expand Up @@ -151,9 +153,27 @@ && matchIfPresent(variable.getYear(), labels.get(Post.ARCHIVE_YEAR_LABEL))
&& matchIfPresent(variable.getMonth(), labels.get(Post.ARCHIVE_MONTH_LABEL))
&& matchIfPresent(variable.getDay(), labels.get(Post.ARCHIVE_DAY_LABEL));
})
.filterWhen(post -> {
if (isNotBlank(variable.getCategorySlug())) {
var categoryNames = post.getSpec().getCategories();
return postService.listCategories(categoryNames)
.next()
.filter(category -> category.getSpec().getSlug()
.equals(variable.getCategorySlug())
)
.map(category -> category.getSpec().getSlug())
.switchIfEmpty(Mono.defer(() -> {
if (DEFAULT_CATEGORY.equals(variable.getCategorySlug())) {
return Mono.just(DEFAULT_CATEGORY);
}
return Mono.empty();
}))
.hasElement();
}
return Mono.just(true);
})
.next()
.flatMap(post -> postFinder.getByName(post.getMetadata().getName()))
.switchIfEmpty(Mono.error(new NotFoundException("Post not found")));
.flatMap(post -> postFinder.getByName(post.getMetadata().getName()));
}

Flux<Post> postsByPredicates(PostPatternVariable patternVariable) {
Expand Down Expand Up @@ -196,6 +216,7 @@ static class PostPatternVariable {
String year;
String month;
String day;
String categorySlug;

static PostPatternVariable from(ServerRequest request) {
Map<String, String> variables = mergedVariables(request);
Expand Down
2 changes: 2 additions & 0 deletions application/src/main/resources/extensions/system-setting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ spec:
value: '/{year:\d{4}}/{month:\d{2}}/{slug}'
- label: '/{year}/{month}/{day}/{slug}'
value: '/{year:\d{4}}/{month:\d{2}}/{day:\d{2}}/{slug}'
- label: '/categories/{categorySlug}/{slug}'
value: '/categories/{categorySlug}/{slug}'
name: post
validation: required
- group: codeInjection
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package run.halo.app.content.permalinks;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;

Expand All @@ -10,16 +11,21 @@
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import reactor.core.publisher.Flux;
import run.halo.app.content.PostService;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
Expand All @@ -44,12 +50,17 @@ class PostPermalinkPolicyTest {
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;

@Mock
private PostService postService;

private PostPermalinkPolicy postPermalinkPolicy;

@BeforeEach
void setUp() {
lenient().when(externalUrlSupplier.get()).thenReturn(URI.create(""));
postPermalinkPolicy = new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier);
lenient().when(postService.listCategories(any())).thenReturn(Flux.empty());
postPermalinkPolicy =
new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier, postService);
}

@Test
Expand Down Expand Up @@ -93,6 +104,24 @@ void permalink() {
assertThat(permalink).isEqualTo("/posts/test-post");
}

@Test
void permalinkForCategory() {
Post post = TestPost.postV1();
post.getSpec().setCategories(List.of("test-category"));
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(post);
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{categorySlug}/{slug}");
post.getMetadata().setName("test-post");
post.getSpec().setSlug("test-post-slug");
Instant now = Instant.now();
post.getSpec().setPublishTime(now);

var category = createCategory("test-category", "test-category-slug");
when(postService.listCategories(post.getSpec().getCategories()))
.thenReturn(Flux.just(category));
var permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("/test-category-slug/test-post-slug");
}

@Test
void permalinkWithExternalUrl() {
Post post = TestPost.postV1();
Expand All @@ -112,4 +141,18 @@ void permalinkWithExternalUrl() {
permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("http://example.com/2022/11/01/%E4%B8%AD%E6%96%87%20slug");
}

private Category createCategory(String name, String slug) {
Category category = new Category();
Metadata metadata = new Metadata();
metadata.setName(name);
category.setMetadata(metadata);
category.setSpec(new Category.CategorySpec());
category.setStatus(new Category.CategoryStatus());

category.getSpec().setDisplayName("display-name");
category.getSpec().setSlug(slug);
category.getSpec().setPriority(0);
return category;
}
}
Loading